summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-09-19 01:45:44 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-09-19 01:45:44 +0000
commit85dc423f7090da0a52c73eb66faf22ddb20efff9 (patch)
tree9160f299afd8c80c038f08e1545be119f5e3f1e1 /spec
parent15c2c8c66dbe422588e5411eee7e68f1fa440bb8 (diff)
downloadgitlab-ce-85dc423f7090da0a52c73eb66faf22ddb20efff9.tar.gz
Add latest changes from gitlab-org/gitlab@13-4-stable-ee
Diffstat (limited to 'spec')
-rw-r--r--spec/bin/feature_flag_spec.rb40
-rw-r--r--spec/controllers/admin/cohorts_controller_spec.rb39
-rw-r--r--spec/controllers/admin/dev_ops_report_controller_spec.rb39
-rw-r--r--spec/controllers/admin/instance_statistics_controller_spec.rb17
-rw-r--r--spec/controllers/admin/integrations_controller_spec.rb7
-rw-r--r--spec/controllers/admin/plan_limits_controller_spec.rb45
-rw-r--r--spec/controllers/admin/sessions_controller_spec.rb31
-rw-r--r--spec/controllers/admin/users_controller_spec.rb139
-rw-r--r--spec/controllers/application_controller_spec.rb55
-rw-r--r--spec/controllers/concerns/redis_tracking_spec.rb106
-rw-r--r--spec/controllers/concerns/send_file_upload_spec.rb140
-rw-r--r--spec/controllers/dashboard/projects_controller_spec.rb36
-rw-r--r--spec/controllers/graphql_controller_spec.rb40
-rw-r--r--spec/controllers/groups/settings/integrations_controller_spec.rb6
-rw-r--r--spec/controllers/groups_controller_spec.rb4
-rw-r--r--spec/controllers/instance_statistics/cohorts_controller_spec.rb28
-rw-r--r--spec/controllers/instance_statistics/dev_ops_score_controller_spec.rb20
-rw-r--r--spec/controllers/invites_controller_spec.rb140
-rw-r--r--spec/controllers/jira_connect/app_descriptor_controller_spec.rb23
-rw-r--r--spec/controllers/jira_connect/events_controller_spec.rb73
-rw-r--r--spec/controllers/jira_connect/subscriptions_controller_spec.rb113
-rw-r--r--spec/controllers/ldap/omniauth_callbacks_controller_spec.rb5
-rw-r--r--spec/controllers/oauth/jira/authorizations_controller_spec.rb45
-rw-r--r--spec/controllers/oauth/token_info_controller_spec.rb16
-rw-r--r--spec/controllers/omniauth_callbacks_controller_spec.rb50
-rw-r--r--spec/controllers/passwords_controller_spec.rb57
-rw-r--r--spec/controllers/profiles/accounts_controller_spec.rb13
-rw-r--r--spec/controllers/profiles/emails_controller_spec.rb37
-rw-r--r--spec/controllers/profiles/keys_controller_spec.rb104
-rw-r--r--spec/controllers/profiles/notifications_controller_spec.rb38
-rw-r--r--spec/controllers/profiles/two_factor_auths_controller_spec.rb42
-rw-r--r--spec/controllers/profiles/webauthn_registrations_controller_spec.rb20
-rw-r--r--spec/controllers/projects/badges_controller_spec.rb28
-rw-r--r--spec/controllers/projects/blob_controller_spec.rb35
-rw-r--r--spec/controllers/projects/ci/lints_controller_spec.rb70
-rw-r--r--spec/controllers/projects/graphs_controller_spec.rb2
-rw-r--r--spec/controllers/projects/issue_links_controller_spec.rb71
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb89
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb52
-rw-r--r--spec/controllers/projects/merge_requests/content_controller_spec.rb18
-rw-r--r--spec/controllers/projects/merge_requests/diffs_controller_spec.rb48
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb36
-rw-r--r--spec/controllers/projects/milestones_controller_spec.rb8
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb2
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb69
-rw-r--r--spec/controllers/projects/services_controller_spec.rb15
-rw-r--r--spec/controllers/projects/settings/operations_controller_spec.rb7
-rw-r--r--spec/controllers/projects/snippets_controller_spec.rb83
-rw-r--r--spec/controllers/projects/static_site_editor_controller_spec.rb23
-rw-r--r--spec/controllers/projects/todos_controller_spec.rb35
-rw-r--r--spec/controllers/projects/web_ide_schemas_controller_spec.rb66
-rw-r--r--spec/controllers/projects/web_ide_terminals_controller_spec.rb2
-rw-r--r--spec/controllers/projects_controller_spec.rb43
-rw-r--r--spec/controllers/registrations/experience_levels_controller_spec.rb2
-rw-r--r--spec/controllers/registrations_controller_spec.rb59
-rw-r--r--spec/controllers/search_controller_spec.rb107
-rw-r--r--spec/controllers/sessions_controller_spec.rb93
-rw-r--r--spec/controllers/snippets_controller_spec.rb14
-rw-r--r--spec/controllers/users_controller_spec.rb65
-rw-r--r--spec/db/schema_spec.rb5
-rw-r--r--spec/docs_screenshots/container_registry_docs.rb87
-rw-r--r--spec/factories/atlassian_identities.rb11
-rw-r--r--spec/factories/audit_events.rb4
-rw-r--r--spec/factories/ci/bridge.rb9
-rw-r--r--spec/factories/ci/build_pending_states.rb9
-rw-r--r--spec/factories/ci/pipeline_artifacts.rb23
-rw-r--r--spec/factories/ci/pipelines.rb6
-rw-r--r--spec/factories/ci_platform_metrics.rb9
-rw-r--r--spec/factories/clusters/kubernetes_namespaces.rb15
-rw-r--r--spec/factories/clusters/providers/aws.rb1
-rw-r--r--spec/factories/dev_ops_report_metrics.rb (renamed from spec/factories/dev_ops_score_metrics.rb)2
-rw-r--r--spec/factories/diff_position.rb5
-rw-r--r--spec/factories/draft_note.rb2
-rw-r--r--spec/factories/file_uploaders.rb2
-rw-r--r--spec/factories/group_members.rb8
-rw-r--r--spec/factories/instance_statistics/measurement.rb17
-rw-r--r--spec/factories/issuable_severity.rb7
-rw-r--r--spec/factories/issue_links.rb8
-rw-r--r--spec/factories/issues.rb10
-rw-r--r--spec/factories/jira_connect_installation.rb9
-rw-r--r--spec/factories/jira_connect_subscription.rb8
-rw-r--r--spec/factories/labels.rb7
-rw-r--r--spec/factories/merge_request_diffs.rb16
-rw-r--r--spec/factories/merge_requests.rb2
-rw-r--r--spec/factories/metrics/users_starred_dashboards.rb (renamed from spec/factories/metrics/users_starred_dasboards.rb)0
-rw-r--r--spec/factories/operations/feature_flag_scopes.rb10
-rw-r--r--spec/factories/operations/feature_flags.rb17
-rw-r--r--spec/factories/operations/feature_flags/scope.rb8
-rw-r--r--spec/factories/operations/feature_flags/strategy.rb9
-rw-r--r--spec/factories/operations/feature_flags/user_list.rb9
-rw-r--r--spec/factories/operations/feature_flags_clients.rb7
-rw-r--r--spec/factories/packages.rb21
-rw-r--r--spec/factories/pages_deployments.rb12
-rw-r--r--spec/factories/plan_limits.rb9
-rw-r--r--spec/factories/project_feature_usage.rb15
-rw-r--r--spec/factories/project_members.rb2
-rw-r--r--spec/factories/project_statistics.rb1
-rw-r--r--spec/factories/projects.rb33
-rw-r--r--spec/factories/resource_iteration_event.rb11
-rw-r--r--spec/factories/services.rb6
-rw-r--r--spec/factories/snippet_repositories.rb8
-rw-r--r--spec/factories/terraform/state.rb20
-rw-r--r--spec/factories/terraform/state_version.rb11
-rw-r--r--spec/factories/usage_data.rb16
-rw-r--r--spec/factories/users.rb18
-rw-r--r--spec/factories/webauthn_registrations.rb11
-rw-r--r--spec/features/admin/admin_cohorts_spec.rb31
-rw-r--r--spec/features/admin/admin_dev_ops_report_spec.rb (renamed from spec/features/instance_statistics/dev_ops_score_spec.rb)24
-rw-r--r--spec/features/admin/admin_groups_spec.rb2
-rw-r--r--spec/features/admin/admin_settings_spec.rb81
-rw-r--r--spec/features/admin/admin_users_spec.rb7
-rw-r--r--spec/features/admin/services/admin_activates_prometheus_spec.rb2
-rw-r--r--spec/features/admin/services/admin_visits_service_templates_spec.rb2
-rw-r--r--spec/features/boards/boards_spec.rb60
-rw-r--r--spec/features/boards/new_issue_spec.rb43
-rw-r--r--spec/features/boards/sidebar_spec.rb2
-rw-r--r--spec/features/cycle_analytics_spec.rb13
-rw-r--r--spec/features/dashboard/datetime_on_tooltips_spec.rb35
-rw-r--r--spec/features/dashboard/instance_statistics_spec.rb64
-rw-r--r--spec/features/expand_collapse_diffs_spec.rb5
-rw-r--r--spec/features/file_uploads/ci_artifact_spec.rb29
-rw-r--r--spec/features/file_uploads/git_lfs_spec.rb37
-rw-r--r--spec/features/file_uploads/graphql_add_design_spec.rb54
-rw-r--r--spec/features/file_uploads/group_import_spec.rb32
-rw-r--r--spec/features/file_uploads/maven_package_spec.rb29
-rw-r--r--spec/features/file_uploads/nuget_package_spec.rb35
-rw-r--r--spec/features/file_uploads/project_import_spec.rb31
-rw-r--r--spec/features/file_uploads/user_avatar_spec.rb33
-rw-r--r--spec/features/groups/board_sidebar_spec.rb2
-rw-r--r--spec/features/groups/clusters/user_spec.rb2
-rw-r--r--spec/features/groups/import_export/import_file_spec.rb2
-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.rb2
-rw-r--r--spec/features/groups/members/manage_members_spec.rb2
-rw-r--r--spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb2
-rw-r--r--spec/features/groups/members/master_manages_access_requests_spec.rb4
-rw-r--r--spec/features/groups/members/search_members_spec.rb2
-rw-r--r--spec/features/groups/members/sort_members_spec.rb2
-rw-r--r--spec/features/groups/milestone_spec.rb2
-rw-r--r--spec/features/groups/navbar_spec.rb5
-rw-r--r--spec/features/import/manifest_import_spec.rb2
-rw-r--r--spec/features/instance_statistics/cohorts_spec.rb19
-rw-r--r--spec/features/instance_statistics/instance_statistics_spec.rb25
-rw-r--r--spec/features/invites_spec.rb15
-rw-r--r--spec/features/issuables/close_reopen_report_toggle_spec.rb16
-rw-r--r--spec/features/issuables/issuable_list_spec.rb4
-rw-r--r--spec/features/issuables/related_issues_spec.rb400
-rw-r--r--spec/features/issuables/sorting_list_spec.rb14
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb23
-rw-r--r--spec/features/issues/group_label_sidebar_spec.rb4
-rw-r--r--spec/features/issues/incident_issue_spec.rb23
-rw-r--r--spec/features/issues/issue_detail_spec.rb13
-rw-r--r--spec/features/issues/issue_sidebar_spec.rb6
-rw-r--r--spec/features/issues/markdown_toolbar_spec.rb2
-rw-r--r--spec/features/issues/resource_label_events_spec.rb4
-rw-r--r--spec/features/issues/service_desk_spec.rb100
-rw-r--r--spec/features/issues/user_creates_issue_spec.rb48
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb30
-rw-r--r--spec/features/issues/user_interacts_with_awards_spec.rb25
-rw-r--r--spec/features/issues/user_views_issue_spec.rb30
-rw-r--r--spec/features/jira_connect/subscriptions_spec.rb47
-rw-r--r--spec/features/jira_oauth_provider_authorize_spec.rb21
-rw-r--r--spec/features/labels_hierarchy_spec.rb9
-rw-r--r--spec/features/markdown/copy_as_gfm_spec.rb56
-rw-r--r--spec/features/markdown/keyboard_shortcuts_spec.rb119
-rw-r--r--spec/features/markdown/markdown_spec.rb1
-rw-r--r--spec/features/markdown/mermaid_spec.rb2
-rw-r--r--spec/features/merge_request/batch_comments_spec.rb2
-rw-r--r--spec/features/merge_request/maintainer_edits_fork_spec.rb2
-rw-r--r--spec/features/merge_request/user_accepts_merge_request_spec.rb34
-rw-r--r--spec/features/merge_request/user_assigns_themselves_spec.rb9
-rw-r--r--spec/features/merge_request/user_edits_mr_spec.rb28
-rw-r--r--spec/features/merge_request/user_expands_diff_spec.rb11
-rw-r--r--spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb3
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb2
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb4
-rw-r--r--spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb1
-rw-r--r--spec/features/merge_request/user_sees_diff_spec.rb10
-rw-r--r--spec/features/merge_request/user_sees_merge_widget_spec.rb4
-rw-r--r--spec/features/merge_request/user_sees_pipelines_spec.rb4
-rw-r--r--spec/features/merge_request/user_sees_versions_spec.rb2
-rw-r--r--spec/features/merge_request/user_views_auto_expanding_diff_spec.rb6
-rw-r--r--spec/features/merge_request/user_views_diffs_file_by_file_spec.rb2
-rw-r--r--spec/features/merge_request/user_views_diffs_spec.rb3
-rw-r--r--spec/features/merge_requests/user_views_diffs_commit_spec.rb1
-rw-r--r--spec/features/profiles/account_spec.rb33
-rw-r--r--spec/features/projects/artifacts/raw_spec.rb2
-rw-r--r--spec/features/projects/blobs/blob_show_spec.rb79
-rw-r--r--spec/features/projects/branches_spec.rb2
-rw-r--r--spec/features/projects/ci/lint_spec.rb139
-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/clusters_spec.rb4
-rw-r--r--spec/features/projects/commit/mini_pipeline_graph_spec.rb2
-rw-r--r--spec/features/projects/deploy_keys_spec.rb2
-rw-r--r--spec/features/projects/environments/environments_spec.rb4
-rw-r--r--spec/features/projects/issues/design_management/user_paginates_designs_spec.rb59
-rw-r--r--spec/features/projects/issues/design_management/user_permissions_upload_spec.rb29
-rw-r--r--spec/features/projects/issues/design_management/user_uploads_designs_spec.rb79
-rw-r--r--spec/features/projects/issues/design_management/user_views_design_spec.rb39
-rw-r--r--spec/features/projects/issues/design_management/user_views_designs_spec.rb77
-rw-r--r--spec/features/projects/jobs_spec.rb49
-rw-r--r--spec/features/projects/members/invite_group_spec.rb2
-rw-r--r--spec/features/projects/navbar_spec.rb8
-rw-r--r--spec/features/projects/pages_spec.rb4
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb2
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb8
-rw-r--r--spec/features/projects/releases/user_creates_release_spec.rb147
-rw-r--r--spec/features/projects/releases/user_views_releases_spec.rb156
-rw-r--r--spec/features/projects/services/user_activates_asana_spec.rb2
-rw-r--r--spec/features/projects/services/user_activates_assembla_spec.rb4
-rw-r--r--spec/features/projects/services/user_activates_atlassian_bamboo_ci_spec.rb4
-rw-r--r--spec/features/projects/services/user_activates_emails_on_push_spec.rb4
-rw-r--r--spec/features/projects/services/user_activates_flowdock_spec.rb4
-rw-r--r--spec/features/projects/services/user_activates_hipchat_spec.rb10
-rw-r--r--spec/features/projects/services/user_activates_irker_spec.rb4
-rw-r--r--spec/features/projects/services/user_activates_issue_tracker_spec.rb23
-rw-r--r--spec/features/projects/services/user_activates_jetbrains_teamcity_ci_spec.rb4
-rw-r--r--spec/features/projects/services/user_activates_jira_spec.rb15
-rw-r--r--spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb10
-rw-r--r--spec/features/projects/services/user_activates_packagist_spec.rb2
-rw-r--r--spec/features/projects/services/user_activates_pivotaltracker_spec.rb4
-rw-r--r--spec/features/projects/services/user_activates_prometheus_spec.rb2
-rw-r--r--spec/features/projects/services/user_activates_pushover_spec.rb4
-rw-r--r--spec/features/projects/services/user_activates_slack_notifications_spec.rb2
-rw-r--r--spec/features/projects/services/user_activates_slack_slash_command_spec.rb6
-rw-r--r--spec/features/projects/settings/repository_settings_spec.rb8
-rw-r--r--spec/features/projects/settings/user_renames_a_project_spec.rb2
-rw-r--r--spec/features/projects/show/user_sees_readme_spec.rb21
-rw-r--r--spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb2
-rw-r--r--spec/features/projects/snippets/create_snippet_spec.rb138
-rw-r--r--spec/features/projects/snippets/user_updates_snippet_spec.rb80
-rw-r--r--spec/features/projects/user_sees_sidebar_spec.rb5
-rw-r--r--spec/features/projects/view_on_env_spec.rb2
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb1
-rw-r--r--spec/features/projects_spec.rb2
-rw-r--r--spec/features/search/user_searches_for_issues_spec.rb85
-rw-r--r--spec/features/search/user_searches_for_projects_spec.rb45
-rw-r--r--spec/features/search/user_uses_header_search_field_spec.rb4
-rw-r--r--spec/features/snippets/spam_snippets_spec.rb19
-rw-r--r--spec/features/snippets/user_creates_snippet_spec.rb197
-rw-r--r--spec/features/snippets/user_edits_snippet_spec.rb131
-rw-r--r--spec/features/static_site_editor_spec.rb8
-rw-r--r--spec/features/task_lists_spec.rb12
-rw-r--r--spec/features/u2f_spec.rb115
-rw-r--r--spec/features/users/login_spec.rb2
-rw-r--r--spec/features/users/signup_spec.rb105
-rw-r--r--spec/features/webauthn_spec.rb234
-rw-r--r--spec/finders/ci/jobs_finder_spec.rb140
-rw-r--r--spec/finders/concerns/finder_methods_spec.rb6
-rw-r--r--spec/finders/design_management/designs_finder_spec.rb20
-rw-r--r--spec/finders/feature_flags_finder_spec.rb94
-rw-r--r--spec/finders/fork_targets_finder_spec.rb8
-rw-r--r--spec/finders/group_members_finder_spec.rb1
-rw-r--r--spec/finders/groups_finder_spec.rb8
-rw-r--r--spec/finders/issues_finder_spec.rb219
-rw-r--r--spec/finders/members_finder_spec.rb12
-rw-r--r--spec/finders/merge_requests_finder_spec.rb66
-rw-r--r--spec/finders/projects_finder_spec.rb36
-rw-r--r--spec/finders/user_group_notification_settings_finder_spec.rb132
-rw-r--r--spec/finders/users_finder_spec.rb17
-rw-r--r--spec/fixtures/api/schemas/entities/discussion.json3
-rw-r--r--spec/fixtures/api/schemas/entities/github/branches.json16
-rw-r--r--spec/fixtures/api/schemas/entities/github/commit.json61
-rw-r--r--spec/fixtures/api/schemas/entities/github/pull_request.json108
-rw-r--r--spec/fixtures/api/schemas/entities/github/pull_requests.json6
-rw-r--r--spec/fixtures/api/schemas/entities/github/repositories.json16
-rw-r--r--spec/fixtures/api/schemas/entities/github/repository.json16
-rw-r--r--spec/fixtures/api/schemas/entities/github/user.json13
-rw-r--r--spec/fixtures/api/schemas/entities/group_group_link.json29
-rw-r--r--spec/fixtures/api/schemas/entities/issue_sidebar.json5
-rw-r--r--spec/fixtures/api/schemas/entities/lint_job_entity.json58
-rw-r--r--spec/fixtures/api/schemas/entities/lint_result_entity.json25
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_basic.json7
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_poll_cached_widget.json6
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_poll_widget.json8
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_sidebar.json4
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_sidebar_extras.json4
-rw-r--r--spec/fixtures/api/schemas/group_group_links.json6
-rw-r--r--spec/fixtures/api/schemas/group_member.json78
-rw-r--r--spec/fixtures/api/schemas/group_members.json6
-rw-r--r--spec/fixtures/api/schemas/issue.json2
-rw-r--r--spec/fixtures/api/schemas/jira_connect/author.json12
-rw-r--r--spec/fixtures/api/schemas/jira_connect/branch.json19
-rw-r--r--spec/fixtures/api/schemas/jira_connect/commit.json29
-rw-r--r--spec/fixtures/api/schemas/jira_connect/file.json14
-rw-r--r--spec/fixtures/api/schemas/jira_connect/pull_request.json26
-rw-r--r--spec/fixtures/api/schemas/jira_connect/repository.json34
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issue_link.json20
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issue_links.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/milestone.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/milestone_with_stats.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/packages/collection_package.json34
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/packages/packages.json2
-rw-r--r--spec/fixtures/emails/x_envelope_to_header.eml32
-rw-r--r--spec/fixtures/gitlab/database/structure_example_cleaned.sql16
-rw-r--r--spec/fixtures/importers/bitbucket_server/activities.json75
-rw-r--r--spec/fixtures/importers/bitbucket_server/pull_request.json5
-rw-r--r--spec/fixtures/lib/gitlab/metrics/dashboard/broken_yml_syntax.yml13
-rw-r--r--spec/fixtures/markdown.md.erb9
-rw-r--r--spec/fixtures/pipeline_artifacts/code_coverage_with_multiple_files.json14
-rw-r--r--spec/fixtures/unescaped_chars.po8
-rw-r--r--spec/fixtures/valid.po2
-rw-r--r--spec/fixtures/whats_new/01.yml2
-rw-r--r--spec/fixtures/whats_new/02.yml2
-rw-r--r--spec/fixtures/whats_new/05.yml2
-rw-r--r--spec/frontend/__mocks__/@gitlab/ui.js10
-rw-r--r--spec/frontend/__mocks__/lodash/debounce.js13
-rw-r--r--spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js27
-rw-r--r--spec/frontend/ajax_loading_spinner_spec.js57
-rw-r--r--spec/frontend/alert_handler_spec.js46
-rw-r--r--spec/frontend/alert_management/components/alert_details_spec.js17
-rw-r--r--spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js19
-rw-r--r--spec/frontend/alert_management/components/alert_management_table_spec.js63
-rw-r--r--spec/frontend/alert_management/components/alert_metrics_spec.js9
-rw-r--r--spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js77
-rw-r--r--spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js6
-rw-r--r--spec/frontend/alert_management/mocks/alerts.json4
-rw-r--r--spec/frontend/api_spec.js23
-rw-r--r--spec/frontend/authentication/u2f/authenticate_spec.js6
-rw-r--r--spec/frontend/authentication/u2f/register_spec.js18
-rw-r--r--spec/frontend/authentication/webauthn/authenticate_spec.js132
-rw-r--r--spec/frontend/authentication/webauthn/error_spec.js50
-rw-r--r--spec/frontend/authentication/webauthn/mock_webauthn_device.js35
-rw-r--r--spec/frontend/authentication/webauthn/register_spec.js131
-rw-r--r--spec/frontend/authentication/webauthn/util.js19
-rw-r--r--spec/frontend/awards_handler_spec.js46
-rw-r--r--spec/frontend/badges/components/badge_settings_spec.js10
-rw-r--r--spec/frontend/batch_comments/mock_data.js1
-rw-r--r--spec/frontend/behaviors/autosize_spec.js18
-rw-r--r--spec/frontend/behaviors/gl_emoji_spec.js15
-rw-r--r--spec/frontend/blob/components/blob_content_spec.js18
-rw-r--r--spec/frontend/blob/components/blob_edit_content_spec.js21
-rw-r--r--spec/frontend/blob/components/blob_edit_header_spec.js2
-rw-r--r--spec/frontend/blob/components/blob_embeddable_spec.js35
-rw-r--r--spec/frontend/blob/pipeline_tour_success_mock_data.js1
-rw-r--r--spec/frontend/blob/pipeline_tour_success_modal_spec.js55
-rw-r--r--spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js4
-rw-r--r--spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js67
-rw-r--r--spec/frontend/blob_edit/blob_bundle_spec.js3
-rw-r--r--spec/frontend/blob_edit/edit_blob_spec.js31
-rw-r--r--spec/frontend/boards/board_list_helper.js6
-rw-r--r--spec/frontend/boards/board_list_spec.js6
-rw-r--r--spec/frontend/boards/board_new_issue_spec.js59
-rw-r--r--spec/frontend/boards/boards_store_spec.js2
-rw-r--r--spec/frontend/boards/components/board_card_layout_spec.js95
-rw-r--r--spec/frontend/boards/components/board_card_spec.js (renamed from spec/frontend/boards/board_card_spec.js)52
-rw-r--r--spec/frontend/boards/components/board_column_spec.js5
-rw-r--r--spec/frontend/boards/components/board_content_spec.js64
-rw-r--r--spec/frontend/boards/components/board_form_spec.js2
-rw-r--r--spec/frontend/boards/components/board_list_header_spec.js8
-rw-r--r--spec/frontend/boards/components/board_settings_sidebar_spec.js174
-rw-r--r--spec/frontend/boards/components/boards_selector_spec.js2
-rw-r--r--spec/frontend/boards/components/issuable_title_spec.js33
-rw-r--r--spec/frontend/boards/components/issue_count_spec.js2
-rw-r--r--spec/frontend/boards/components/sidebar/board_editable_item_spec.js107
-rw-r--r--spec/frontend/boards/issue_card_spec.js6
-rw-r--r--spec/frontend/boards/list_spec.js1
-rw-r--r--spec/frontend/boards/mock_data.js176
-rw-r--r--spec/frontend/boards/stores/actions_spec.js369
-rw-r--r--spec/frontend/boards/stores/getters_spec.js112
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js330
-rw-r--r--spec/frontend/branches/ajax_loading_spinner_spec.js32
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js2
-rw-r--r--spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap8
-rw-r--r--spec/frontend/clusters/components/application_row_spec.js34
-rw-r--r--spec/frontend/clusters/components/fluentd_output_settings_spec.js2
-rw-r--r--spec/frontend/clusters/components/knative_domain_editor_spec.js11
-rw-r--r--spec/frontend/clusters/components/new_cluster_spec.js41
-rw-r--r--spec/frontend/clusters/components/uninstall_application_button_spec.js17
-rw-r--r--spec/frontend/clusters_list/components/ancestor_notice_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/clusters_spec.js7
-rw-r--r--spec/frontend/collapsed_sidebar_todo_spec.js12
-rw-r--r--spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap12
-rw-r--r--spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js2
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js106
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js8
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/actions_spec.js3
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js6
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js18
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js1
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js2
-rw-r--r--spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js4
-rw-r--r--spec/frontend/deploy_keys/components/key_spec.js10
-rw-r--r--spec/frontend/deprecated_jquery_dropdown_spec.js (renamed from spec/frontend/gl_dropdown_spec.js)54
-rw-r--r--spec/frontend/design_management/components/delete_button_spec.js19
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap21
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap14
-rw-r--r--spec/frontend/design_management/components/design_notes/design_discussion_spec.js80
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_spec.js22
-rw-r--r--spec/frontend/design_management/components/design_notes/design_reply_form_spec.js20
-rw-r--r--spec/frontend/design_management/components/design_overlay_spec.js57
-rw-r--r--spec/frontend/design_management/components/design_presentation_spec.js2
-rw-r--r--spec/frontend/design_management/components/design_sidebar_spec.js41
-rw-r--r--spec/frontend/design_management/components/design_todo_button_spec.js158
-rw-r--r--spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap40
-rw-r--r--spec/frontend/design_management/components/list/item_spec.js3
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap24
-rw-r--r--spec/frontend/design_management/components/upload/design_version_dropdown_spec.js14
-rw-r--r--spec/frontend/design_management/mock_data/apollo_mock.js18
-rw-r--r--spec/frontend/design_management/mock_data/design.js3
-rw-r--r--spec/frontend/design_management/mock_data/discussion.js45
-rw-r--r--spec/frontend/design_management/mock_data/notes.js74
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap2
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap14
-rw-r--r--spec/frontend/design_management/pages/design/index_spec.js134
-rw-r--r--spec/frontend/design_management/pages/index_apollo_spec.js162
-rw-r--r--spec/frontend/design_management/pages/index_spec.js140
-rw-r--r--spec/frontend/design_management/router_spec.js5
-rw-r--r--spec/frontend/design_management/utils/cache_update_spec.js13
-rw-r--r--spec/frontend/design_management/utils/design_management_utils_spec.js17
-rw-r--r--spec/frontend/design_management_legacy/components/__snapshots__/design_note_pin_spec.js.snap42
-rw-r--r--spec/frontend/design_management_legacy/components/__snapshots__/design_presentation_spec.js.snap104
-rw-r--r--spec/frontend/design_management_legacy/components/__snapshots__/design_scaler_spec.js.snap115
-rw-r--r--spec/frontend/design_management_legacy/components/__snapshots__/image_spec.js.snap68
-rw-r--r--spec/frontend/design_management_legacy/components/delete_button_spec.js51
-rw-r--r--spec/frontend/design_management_legacy/components/design_note_pin_spec.js49
-rw-r--r--spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_note_spec.js.snap67
-rw-r--r--spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_reply_form_spec.js.snap15
-rw-r--r--spec/frontend/design_management_legacy/components/design_notes/design_discussion_spec.js318
-rw-r--r--spec/frontend/design_management_legacy/components/design_notes/design_note_spec.js170
-rw-r--r--spec/frontend/design_management_legacy/components/design_notes/design_reply_form_spec.js184
-rw-r--r--spec/frontend/design_management_legacy/components/design_notes/toggle_replies_widget_spec.js98
-rw-r--r--spec/frontend/design_management_legacy/components/design_overlay_spec.js410
-rw-r--r--spec/frontend/design_management_legacy/components/design_presentation_spec.js553
-rw-r--r--spec/frontend/design_management_legacy/components/design_scaler_spec.js67
-rw-r--r--spec/frontend/design_management_legacy/components/design_sidebar_spec.js236
-rw-r--r--spec/frontend/design_management_legacy/components/image_spec.js133
-rw-r--r--spec/frontend/design_management_legacy/components/list/__snapshots__/item_spec.js.snap149
-rw-r--r--spec/frontend/design_management_legacy/components/list/item_spec.js169
-rw-r--r--spec/frontend/design_management_legacy/components/toolbar/__snapshots__/index_spec.js.snap61
-rw-r--r--spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_button_spec.js.snap28
-rw-r--r--spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_spec.js.snap29
-rw-r--r--spec/frontend/design_management_legacy/components/toolbar/index_spec.js123
-rw-r--r--spec/frontend/design_management_legacy/components/toolbar/pagination_button_spec.js61
-rw-r--r--spec/frontend/design_management_legacy/components/toolbar/pagination_spec.js79
-rw-r--r--spec/frontend/design_management_legacy/components/upload/__snapshots__/button_spec.js.snap79
-rw-r--r--spec/frontend/design_management_legacy/components/upload/__snapshots__/design_dropzone_spec.js.snap455
-rw-r--r--spec/frontend/design_management_legacy/components/upload/__snapshots__/design_version_dropdown_spec.js.snap111
-rw-r--r--spec/frontend/design_management_legacy/components/upload/button_spec.js59
-rw-r--r--spec/frontend/design_management_legacy/components/upload/design_dropzone_spec.js132
-rw-r--r--spec/frontend/design_management_legacy/components/upload/design_version_dropdown_spec.js122
-rw-r--r--spec/frontend/design_management_legacy/components/upload/mock_data/all_versions.js14
-rw-r--r--spec/frontend/design_management_legacy/mock_data/all_versions.js8
-rw-r--r--spec/frontend/design_management_legacy/mock_data/design.js74
-rw-r--r--spec/frontend/design_management_legacy/mock_data/designs.js17
-rw-r--r--spec/frontend/design_management_legacy/mock_data/no_designs.js11
-rw-r--r--spec/frontend/design_management_legacy/mock_data/notes.js46
-rw-r--r--spec/frontend/design_management_legacy/pages/__snapshots__/index_spec.js.snap263
-rw-r--r--spec/frontend/design_management_legacy/pages/design/__snapshots__/index_spec.js.snap216
-rw-r--r--spec/frontend/design_management_legacy/pages/design/index_spec.js291
-rw-r--r--spec/frontend/design_management_legacy/pages/index_spec.js543
-rw-r--r--spec/frontend/design_management_legacy/router_spec.js82
-rw-r--r--spec/frontend/design_management_legacy/utils/cache_update_spec.js44
-rw-r--r--spec/frontend/design_management_legacy/utils/design_management_utils_spec.js176
-rw-r--r--spec/frontend/design_management_legacy/utils/error_messages_spec.js62
-rw-r--r--spec/frontend/design_management_legacy/utils/tracking_spec.js59
-rw-r--r--spec/frontend/diffs/components/app_spec.js208
-rw-r--r--spec/frontend/diffs/components/collapsed_files_warning_spec.js88
-rw-r--r--spec/frontend/diffs/components/commit_item_spec.js3
-rw-r--r--spec/frontend/diffs/components/compare_versions_spec.js3
-rw-r--r--spec/frontend/diffs/components/diff_content_spec.js28
-rw-r--r--spec/frontend/diffs/components/diff_discussions_spec.js4
-rw-r--r--spec/frontend/diffs/components/diff_expansion_cell_spec.js19
-rw-r--r--spec/frontend/diffs/components/diff_file_header_spec.js19
-rw-r--r--spec/frontend/diffs/components/diff_file_row_spec.js30
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js44
-rw-r--r--spec/frontend/diffs/components/diff_stats_spec.js4
-rw-r--r--spec/frontend/diffs/components/image_diff_overlay_spec.js4
-rw-r--r--spec/frontend/diffs/components/inline_diff_expansion_row_spec.js5
-rw-r--r--spec/frontend/diffs/components/inline_diff_table_row_spec.js355
-rw-r--r--spec/frontend/diffs/components/inline_diff_view_spec.js4
-rw-r--r--spec/frontend/diffs/components/merge_conflict_warning_spec.js77
-rw-r--r--spec/frontend/diffs/components/no_changes_spec.js2
-rw-r--r--spec/frontend/diffs/components/parallel_diff_table_row_spec.js259
-rw-r--r--spec/frontend/diffs/components/parallel_diff_view_spec.js40
-rw-r--r--spec/frontend/diffs/components/settings_dropdown_spec.js42
-rw-r--r--spec/frontend/diffs/components/tree_list_spec.js115
-rw-r--r--spec/frontend/diffs/mock_data/diff_file.js111
-rw-r--r--spec/frontend/diffs/mock_data/diff_metadata.js3
-rw-r--r--spec/frontend/diffs/store/actions_spec.js207
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js83
-rw-r--r--spec/frontend/diffs/store/utils_spec.js55
-rw-r--r--spec/frontend/editor/editor_lite_spec.js207
-rw-r--r--spec/frontend/editor/editor_markdown_ext_spec.js58
-rw-r--r--spec/frontend/emoji/emoji_spec.js3
-rw-r--r--spec/frontend/environments/environment_actions_spec.js17
-rw-r--r--spec/frontend/environments/environment_item_spec.js4
-rw-r--r--spec/frontend/environments/environment_monitoring_spec.js8
-rw-r--r--spec/frontend/environments/environment_pin_spec.js7
-rw-r--r--spec/frontend/environments/environment_rollback_spec.js4
-rw-r--r--spec/frontend/environments/environment_terminal_button_spec.js2
-rw-r--r--spec/frontend/environments/environments_app_spec.js6
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js40
-rw-r--r--spec/frontend/error_tracking/components/stacktrace_entry_spec.js7
-rw-r--r--spec/frontend/fixtures/search.rb6
-rw-r--r--spec/frontend/fixtures/static/ajax_loading_spinner.html3
-rw-r--r--spec/frontend/fixtures/static/deprecated_jquery_dropdown.html39
-rw-r--r--spec/frontend/fixtures/static/gl_dropdown.html26
-rw-r--r--spec/frontend/fixtures/u2f.rb4
-rw-r--r--spec/frontend/fixtures/webauthn.rb47
-rw-r--r--spec/frontend/frequent_items/components/frequent_items_search_input_spec.js4
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js7
-rw-r--r--spec/frontend/gpg_badges_spec.js2
-rw-r--r--spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap2
-rw-r--r--spec/frontend/groups/components/invite_members_banner_spec.js144
-rw-r--r--spec/frontend/groups/components/item_actions_spec.js8
-rw-r--r--spec/frontend/groups/components/item_caret_spec.js4
-rw-r--r--spec/frontend/groups/components/item_stats_value_spec.js2
-rw-r--r--spec/frontend/groups/components/item_type_icon_spec.js8
-rw-r--r--spec/frontend/groups/members/index_spec.js66
-rw-r--r--spec/frontend/groups/members/mock_data.js33
-rw-r--r--spec/frontend/header_spec.js2
-rw-r--r--spec/frontend/helpers/dom_events_helper.js1
-rw-r--r--spec/frontend/helpers/fake_request_animation_frame.js1
-rw-r--r--spec/frontend/helpers/jest_helpers.js2
-rw-r--r--spec/frontend/helpers/local_storage_helper.js2
-rw-r--r--spec/frontend/helpers/local_storage_helper_spec.js9
-rw-r--r--spec/frontend/helpers/locale_helper.js2
-rw-r--r--spec/frontend/helpers/mock_apollo_helper.js23
-rw-r--r--spec/frontend/helpers/mock_dom_observer.js4
-rw-r--r--spec/frontend/helpers/startup_css_helper_spec.js65
-rw-r--r--spec/frontend/ide/commit_icon_spec.js1
-rw-r--r--spec/frontend/ide/components/branches/item_spec.js6
-rw-r--r--spec/frontend/ide/components/commit_sidebar/form_spec.js53
-rw-r--r--spec/frontend/ide/components/error_message_spec.js2
-rw-r--r--spec/frontend/ide/components/file_row_extra_spec.js4
-rw-r--r--spec/frontend/ide/components/ide_status_list_spec.js3
-rw-r--r--spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap2
-rw-r--r--spec/frontend/ide/components/jobs/detail/description_spec.js12
-rw-r--r--spec/frontend/ide/components/jobs/detail/scroll_button_spec.js4
-rw-r--r--spec/frontend/ide/components/jobs/detail_spec.js8
-rw-r--r--spec/frontend/ide/components/jobs/item_spec.js2
-rw-r--r--spec/frontend/ide/components/jobs/list_spec.js6
-rw-r--r--spec/frontend/ide/components/merge_requests/item_spec.js2
-rw-r--r--spec/frontend/ide/components/merge_requests/list_spec.js36
-rw-r--r--spec/frontend/ide/components/nav_dropdown_button_spec.js2
-rw-r--r--spec/frontend/ide/components/nav_dropdown_spec.js2
-rw-r--r--spec/frontend/ide/components/new_dropdown/button_spec.js2
-rw-r--r--spec/frontend/ide/components/new_dropdown/upload_spec.js2
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js63
-rw-r--r--spec/frontend/ide/components/preview/clientside_spec.js22
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js13
-rw-r--r--spec/frontend/ide/components/repo_tab_spec.js132
-rw-r--r--spec/frontend/ide/components/repo_tabs_spec.js43
-rw-r--r--spec/frontend/ide/components/terminal/session_spec.js2
-rw-r--r--spec/frontend/ide/components/terminal/terminal_controls_spec.js8
-rw-r--r--spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js9
-rw-r--r--spec/frontend/ide/helpers.js1
-rw-r--r--spec/frontend/ide/lib/editor_spec.js22
-rw-r--r--spec/frontend/ide/lib/errors_spec.js70
-rw-r--r--spec/frontend/ide/lib/files_spec.js19
-rw-r--r--spec/frontend/ide/mock_data.js3
-rw-r--r--spec/frontend/ide/services/index_spec.js6
-rw-r--r--spec/frontend/ide/stores/actions/file_spec.js26
-rw-r--r--spec/frontend/ide/stores/actions/merge_request_spec.js2
-rw-r--r--spec/frontend/ide/stores/actions_spec.js15
-rw-r--r--spec/frontend/ide/stores/getters_spec.js45
-rw-r--r--spec/frontend/ide/stores/integration_spec.js2
-rw-r--r--spec/frontend/ide/stores/modules/commit/actions_spec.js22
-rw-r--r--spec/frontend/ide/stores/modules/commit/mutations_spec.js21
-rw-r--r--spec/frontend/ide/stores/modules/pipelines/actions_spec.js40
-rw-r--r--spec/frontend/ide/stores/modules/pipelines/mutations_spec.js12
-rw-r--r--spec/frontend/ide/stores/mutations/file_spec.js2
-rw-r--r--spec/frontend/ide/stores/mutations_spec.js18
-rw-r--r--spec/frontend/ide/sync_router_and_store_spec.js8
-rw-r--r--spec/frontend/ide/utils_spec.js162
-rw-r--r--spec/frontend/import_projects/components/bitbucket_status_table_spec.js2
-rw-r--r--spec/frontend/import_projects/components/import_projects_table_spec.js114
-rw-r--r--spec/frontend/import_projects/components/imported_project_table_row_spec.js44
-rw-r--r--spec/frontend/import_projects/components/page_query_param_sync_spec.js87
-rw-r--r--spec/frontend/import_projects/components/provider_repo_table_row_spec.js151
-rw-r--r--spec/frontend/import_projects/store/actions_spec.js66
-rw-r--r--spec/frontend/import_projects/store/getters_spec.js27
-rw-r--r--spec/frontend/import_projects/store/mutations_spec.js178
-rw-r--r--spec/frontend/import_projects/utils_spec.js47
-rw-r--r--spec/frontend/incidents/components/incidents_list_spec.js55
-rw-r--r--spec/frontend/incidents/mocks/incidents.json12
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap20
-rw-r--r--spec/frontend/incidents_settings/components/alerts_form_spec.js2
-rw-r--r--spec/frontend/integrations/edit/components/active_checkbox_spec.js76
-rw-r--r--spec/frontend/integrations/edit/components/active_toggle_spec.js81
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js22
-rw-r--r--spec/frontend/integrations/edit/components/jira_issues_fields_spec.js20
-rw-r--r--spec/frontend/integrations/edit/components/override_dropdown_spec.js106
-rw-r--r--spec/frontend/integrations/edit/mock_data.js5
-rw-r--r--spec/frontend/integrations/edit/store/getters_spec.js14
-rw-r--r--spec/frontend/integrations/edit/store/state_spec.js10
-rw-r--r--spec/frontend/integrations/integration_settings_form_spec.js195
-rw-r--r--spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js289
-rw-r--r--spec/frontend/issuable/related_issues/components/issue_token_spec.js241
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_block_spec.js206
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_list_spec.js190
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_root_spec.js341
-rw-r--r--spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js111
-rw-r--r--spec/frontend/issuable_create/components/issuable_create_root_spec.js64
-rw-r--r--spec/frontend/issuable_create/components/issuable_form_spec.js119
-rw-r--r--spec/frontend/issuable_list/components/issuable_item_spec.js185
-rw-r--r--spec/frontend/issuable_list/components/issuable_list_root_spec.js160
-rw-r--r--spec/frontend/issuable_list/components/issuable_tabs_spec.js91
-rw-r--r--spec/frontend/issuable_list/mock_data.js135
-rw-r--r--spec/frontend/issuable_suggestions/components/app_spec.js10
-rw-r--r--spec/frontend/issuable_suggestions/components/item_spec.js13
-rw-r--r--spec/frontend/issue_show/components/app_spec.js122
-rw-r--r--spec/frontend/issue_show/components/description_spec.js26
-rw-r--r--spec/frontend/issue_show/components/edit_actions_spec.js21
-rw-r--r--spec/frontend/issue_show/components/incidents/highlight_bar_spec.js56
-rw-r--r--spec/frontend/issue_show/components/incidents/incident_tabs_spec.js101
-rw-r--r--spec/frontend/issue_show/helpers.js1
-rw-r--r--spec/frontend/issue_show/index_spec.js19
-rw-r--r--spec/frontend/issue_show/issue_spec.js45
-rw-r--r--spec/frontend/issue_show/mock_data.js35
-rw-r--r--spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap (renamed from spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap)0
-rw-r--r--spec/frontend/issues_list/components/issuable_spec.js (renamed from spec/frontend/issuables_list/components/issuable_spec.js)5
-rw-r--r--spec/frontend/issues_list/components/issuables_list_app_spec.js (renamed from spec/frontend/issuables_list/components/issuables_list_app_spec.js)18
-rw-r--r--spec/frontend/issues_list/components/jira_issues_list_root_spec.js (renamed from spec/frontend/issuables_list/components/issuable_list_root_app_spec.js)12
-rw-r--r--spec/frontend/issues_list/issuable_list_test_data.js (renamed from spec/frontend/issuables_list/issuable_list_test_data.js)0
-rw-r--r--spec/frontend/issues_list/service_desk_helper_spec.js28
-rw-r--r--spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap4
-rw-r--r--spec/frontend/jira_import/components/jira_import_form_spec.js6
-rw-r--r--spec/frontend/jira_import/utils/jira_import_utils_spec.js2
-rw-r--r--spec/frontend/jobs/components/artifacts_block_spec.js17
-rw-r--r--spec/frontend/jobs/components/job_app_spec.js1
-rw-r--r--spec/frontend/jobs/components/log/collapsible_section_spec.js4
-rw-r--r--spec/frontend/jobs/components/log/line_header_spec.js4
-rw-r--r--spec/frontend/jobs/components/log/line_spec.js2
-rw-r--r--spec/frontend/jobs/components/log/log_spec.js12
-rw-r--r--spec/frontend/jobs/components/manual_variables_form_spec.js4
-rw-r--r--spec/frontend/jobs/store/helpers.js1
-rw-r--r--spec/frontend/labels_issue_sidebar_spec.js1
-rw-r--r--spec/frontend/lib/utils/axios_startup_calls_spec.js131
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js14
-rw-r--r--spec/frontend/lib/utils/forms_spec.js44
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js34
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js21
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js38
-rw-r--r--spec/frontend/logs/components/environment_logs_spec.js25
-rw-r--r--spec/frontend/logs/components/log_advanced_filters_spec.js3
-rw-r--r--spec/frontend/logs/components/log_control_buttons_spec.js7
-rw-r--r--spec/frontend/logs/components/log_simple_filters_spec.js5
-rw-r--r--spec/frontend/logs/mock_data.js2
-rw-r--r--spec/frontend/logs/stores/getters_spec.js48
-rw-r--r--spec/frontend/matchers.js6
-rw-r--r--spec/frontend/milestones/project_milestone_combobox_spec.js48
-rw-r--r--spec/frontend/mocks/mocks_helper.js1
-rw-r--r--spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap4
-rw-r--r--spec/frontend/monitoring/alert_widget_spec.js2
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap10
-rw-r--r--spec/frontend/monitoring/components/charts/anomaly_spec.js3
-rw-r--r--spec/frontend/monitoring/components/charts/bar_spec.js15
-rw-r--r--spec/frontend/monitoring/components/charts/column_spec.js4
-rw-r--r--spec/frontend/monitoring/components/charts/heatmap_spec.js7
-rw-r--r--spec/frontend/monitoring/components/charts/stacked_column_spec.js10
-rw-r--r--spec/frontend/monitoring/components/charts/time_series_spec.js3
-rw-r--r--spec/frontend/monitoring/components/dashboard_actions_menu_spec.js8
-rw-r--r--spec/frontend/monitoring/components/dashboard_header_spec.js4
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_builder_spec.js4
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_spec.js18
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js11
-rw-r--r--spec/frontend/monitoring/components/dashboards_dropdown_spec.js6
-rw-r--r--spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js8
-rw-r--r--spec/frontend/monitoring/components/embeds/embed_group_spec.js16
-rw-r--r--spec/frontend/monitoring/components/graph_group_spec.js2
-rw-r--r--spec/frontend/monitoring/components/refresh_button_spec.js6
-rw-r--r--spec/frontend/monitoring/fixture_data.js11
-rw-r--r--spec/frontend/monitoring/graph_data.js26
-rw-r--r--spec/frontend/monitoring/mock_data.js45
-rw-r--r--spec/frontend/mr_popover/mr_popover_spec.js2
-rw-r--r--spec/frontend/namespace_select_spec.js27
-rw-r--r--spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap2
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js38
-rw-r--r--spec/frontend/notes/components/discussion_counter_spec.js9
-rw-r--r--spec/frontend/notes/components/discussion_filter_spec.js2
-rw-r--r--spec/frontend/notes/components/discussion_resolve_button_spec.js15
-rw-r--r--spec/frontend/notes/components/note_actions_spec.js34
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js2
-rw-r--r--spec/frontend/notes/helpers.js1
-rw-r--r--spec/frontend/notes/mixins/discussion_navigation_spec.js10
-rw-r--r--spec/frontend/notes/stores/actions_spec.js24
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap46
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap20
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap2
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap22
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap36
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap20
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap234
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap12
-rw-r--r--spec/frontend/packages/details/components/additional_metadata_spec.js2
-rw-r--r--spec/frontend/packages/details/components/app_spec.js122
-rw-r--r--spec/frontend/packages/details/components/composer_installation_spec.js15
-rw-r--r--spec/frontend/packages/details/components/conan_installation_spec.js2
-rw-r--r--spec/frontend/packages/details/components/maven_installation_spec.js2
-rw-r--r--spec/frontend/packages/details/components/npm_installation_spec.js6
-rw-r--r--spec/frontend/packages/details/components/nuget_installation_spec.js2
-rw-r--r--spec/frontend/packages/details/components/package_history_spec.js6
-rw-r--r--spec/frontend/packages/details/components/package_title_spec.js99
-rw-r--r--spec/frontend/packages/details/store/actions_spec.js24
-rw-r--r--spec/frontend/packages/details/store/getters_spec.js4
-rw-r--r--spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap2
-rw-r--r--spec/frontend/packages/list/components/packages_list_app_spec.js49
-rw-r--r--spec/frontend/packages/list/components/packages_list_spec.js4
-rw-r--r--spec/frontend/packages/list/components/packages_sort_spec.js4
-rw-r--r--spec/frontend/packages/list/stores/actions_spec.js3
-rw-r--r--spec/frontend/packages/mock_data.js3
-rw-r--r--spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap178
-rw-r--r--spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap18
-rw-r--r--spec/frontend/packages/shared/components/package_list_row_spec.js22
-rw-r--r--spec/frontend/packages/shared/components/packages_list_loader_spec.js33
-rw-r--r--spec/frontend/packages/shared/components/publish_method_spec.js6
-rw-r--r--spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap11
-rw-r--r--spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js66
-rw-r--r--spec/frontend/pages/dashboard/todos/index/todos_spec.js1
-rw-r--r--spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js2
-rw-r--r--spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js4
-rw-r--r--spec/frontend/pages/projects/graphs/code_coverage_spec.js8
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js1
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js4
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js2
-rw-r--r--spec/frontend/performance_bar/components/detailed_metric_spec.js2
-rw-r--r--spec/frontend/performance_bar/components/request_warning_spec.js2
-rw-r--r--spec/frontend/performance_bar/index_spec.js2
-rw-r--r--spec/frontend/performance_bar/services/performance_bar_service_spec.js43
-rw-r--r--spec/frontend/pipeline_new/components/pipeline_new_form_spec.js106
-rw-r--r--spec/frontend/pipeline_new/mock_data.js12
-rw-r--r--spec/frontend/pipelines/components/pipelines_filtered_search_spec.js3
-rw-r--r--spec/frontend/pipelines/graph/graph_component_spec.js7
-rw-r--r--spec/frontend/pipelines/graph/job_item_spec.js68
-rw-r--r--spec/frontend/pipelines/graph/linked_pipeline_spec.js75
-rw-r--r--spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js47
-rw-r--r--spec/frontend/pipelines/pipeline_graph/mock_data.js80
-rw-r--r--spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js59
-rw-r--r--spec/frontend/pipelines/pipeline_graph/utils_spec.js150
-rw-r--r--spec/frontend/pipelines/pipeline_triggerer_spec.js2
-rw-r--r--spec/frontend/pipelines/pipelines_actions_spec.js6
-rw-r--r--spec/frontend/pipelines/test_reports/stores/getters_spec.js6
-rw-r--r--spec/frontend/pipelines/test_reports/stores/utils_spec.js26
-rw-r--r--spec/frontend/pipelines/test_reports/test_reports_spec.js11
-rw-r--r--spec/frontend/pipelines/test_reports/test_suite_table_spec.js12
-rw-r--r--spec/frontend/pipelines/test_reports/test_summary_spec.js3
-rw-r--r--spec/frontend/pipelines/time_ago_spec.js23
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_status_token_spec.js17
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js32
-rw-r--r--spec/frontend/profile/account/components/delete_account_modal_spec.js82
-rw-r--r--spec/frontend/projects/commits/components/author_select_spec.js19
-rw-r--r--spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap8
-rw-r--r--spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap4
-rw-r--r--spec/frontend/projects/settings/access_dropdown_spec.js1
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js2
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js18
-rw-r--r--spec/frontend/ref/components/ref_selector_spec.js32
-rw-r--r--spec/frontend/registry/explorer/components/delete_button_spec.js1
-rw-r--r--spec/frontend/registry/explorer/components/details_page/details_header_spec.js2
-rw-r--r--spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js2
-rw-r--r--spec/frontend/registry/explorer/components/details_page/tags_list_spec.js1
-rw-r--r--spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js63
-rw-r--r--spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js2
-rw-r--r--spec/frontend/registry/explorer/components/list_page/registry_header_spec.js134
-rw-r--r--spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js2
-rw-r--r--spec/frontend/registry/explorer/pages/list_spec.js2
-rw-r--r--spec/frontend/registry/explorer/stubs.js2
-rw-r--r--spec/frontend/registry/shared/mocks.js1
-rw-r--r--spec/frontend/releases/__snapshots__/util_spec.js.snap113
-rw-r--r--spec/frontend/releases/components/app_index_spec.js141
-rw-r--r--spec/frontend/releases/components/app_show_spec.js2
-rw-r--r--spec/frontend/releases/components/asset_links_form_spec.js36
-rw-r--r--spec/frontend/releases/components/release_block_assets_spec.js2
-rw-r--r--spec/frontend/releases/components/release_block_footer_spec.js7
-rw-r--r--spec/frontend/releases/components/release_block_spec.js4
-rw-r--r--spec/frontend/releases/components/releases_pagination_graphql_spec.js175
-rw-r--r--spec/frontend/releases/components/releases_pagination_rest_spec.js72
-rw-r--r--spec/frontend/releases/components/releases_pagination_spec.js52
-rw-r--r--spec/frontend/releases/components/tag_field_exsting_spec.js7
-rw-r--r--spec/frontend/releases/mock_data.js128
-rw-r--r--spec/frontend/releases/stores/modules/list/actions_spec.js150
-rw-r--r--spec/frontend/releases/stores/modules/list/helpers.js1
-rw-r--r--spec/frontend/releases/stores/modules/list/mutations_spec.js4
-rw-r--r--spec/frontend/releases/util_spec.js55
-rw-r--r--spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js11
-rw-r--r--spec/frontend/reports/accessibility_report/mock_data.js1
-rw-r--r--spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js45
-rw-r--r--spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap6
-rw-r--r--spec/frontend/reports/components/grouped_issues_list_spec.js2
-rw-r--r--spec/frontend/reports/components/grouped_test_reports_app_spec.js13
-rw-r--r--spec/frontend/repository/components/table/index_spec.js28
-rw-r--r--spec/frontend/repository/components/tree_content_spec.js35
-rw-r--r--spec/frontend/repository/components/web_ide_link_spec.js51
-rw-r--r--spec/frontend/repository/log_tree_spec.js43
-rw-r--r--spec/frontend/repository/router_spec.js13
-rw-r--r--spec/frontend/search/components/state_filter_spec.js104
-rw-r--r--spec/frontend/search_autocomplete_spec.js3
-rw-r--r--spec/frontend/serverless/utils.js1
-rw-r--r--spec/frontend/shortcuts_spec.js85
-rw-r--r--spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap40
-rw-r--r--spec/frontend/sidebar/__snapshots__/todo_spec.js.snap2
-rw-r--r--spec/frontend/sidebar/assignees_spec.js5
-rw-r--r--spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js4
-rw-r--r--spec/frontend/sidebar/components/severity/severity_spec.js57
-rw-r--r--spec/frontend/sidebar/components/severity/sidebar_severity_spec.js166
-rw-r--r--spec/frontend/sidebar/issuable_assignees_spec.js59
-rw-r--r--spec/frontend/sidebar/participants_spec.js10
-rw-r--r--spec/frontend/sidebar/sidebar_labels_spec.js124
-rw-r--r--spec/frontend/sidebar/sidebar_move_issue_spec.js6
-rw-r--r--spec/frontend/sidebar/subscriptions_spec.js2
-rw-r--r--spec/frontend/sidebar/todo_spec.js11
-rw-r--r--spec/frontend/snippet/snippet_bundle_spec.js8
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap6
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap8
-rw-r--r--spec/frontend/snippets/components/edit_spec.js23
-rw-r--r--spec/frontend/snippets/components/embed_dropdown_spec.js70
-rw-r--r--spec/frontend/snippets/components/show_spec.js12
-rw-r--r--spec/frontend/snippets/components/snippet_blob_edit_spec.js17
-rw-r--r--spec/frontend/snippets/components/snippet_header_spec.js8
-rw-r--r--spec/frontend/snippets/components/snippet_visibility_edit_spec.js118
-rw-r--r--spec/frontend/static_site_editor/components/edit_area_spec.js89
-rw-r--r--spec/frontend/static_site_editor/components/edit_drawer_spec.js68
-rw-r--r--spec/frontend/static_site_editor/components/front_matter_controls_spec.js78
-rw-r--r--spec/frontend/static_site_editor/components/publish_toolbar_spec.js17
-rw-r--r--spec/frontend/static_site_editor/graphql/resolvers/file_spec.js2
-rw-r--r--spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js2
-rw-r--r--spec/frontend/static_site_editor/mock_data.js17
-rw-r--r--spec/frontend/static_site_editor/pages/home_spec.js2
-rw-r--r--spec/frontend/static_site_editor/services/formatter_spec.js15
-rw-r--r--spec/frontend/static_site_editor/services/load_source_content_spec.js7
-rw-r--r--spec/frontend/static_site_editor/services/parse_source_file_spec.js62
-rw-r--r--spec/frontend/static_site_editor/services/submit_content_changes_spec.js2
-rw-r--r--spec/frontend/tooltips/components/tooltips_spec.js202
-rw-r--r--spec/frontend/tooltips/index_spec.js149
-rw-r--r--spec/frontend/tracking_spec.js22
-rw-r--r--spec/frontend/vue_mr_widget/components/mock_data.js1
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js10
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js12
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js4
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js329
-rw-r--r--spec/frontend/vue_mr_widget/components/review_app_link_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js6
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js6
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js6
-rw-r--r--spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js12
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js21
-rw-r--r--spec/frontend/vue_mr_widget/mock_data.js1
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/stores/get_state_key_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js32
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap12
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap8
-rw-r--r--spec/frontend/vue_shared/components/actions_button_spec.js203
-rw-r--r--spec/frontend/vue_shared/components/alert_detail_table_spec.js74
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/changed_file_icon_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/clone_dropdown_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/commit_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/confirm_modal_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js16
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/file_finder/index_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/file_row_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js138
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js203
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js73
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js97
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js207
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js106
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js102
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js190
-rw-r--r--spec/frontend/vue_shared/components/icon_spec.js78
-rw-r--r--spec/frontend/vue_shared/components/issue/issue_milestone_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/markdown/header_spec.js58
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js20
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js20
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js47
-rw-r--r--spec/frontend/vue_shared/components/notes/noteable_warning_spec.js8
-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/paginated_list_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap60
-rw-r--r--spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap (renamed from spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap)8
-rw-r--r--spec/frontend/vue_shared/components/registry/code_instruction_spec.js (renamed from spec/frontend/packages/details/components/code_instruction_spec.js)35
-rw-r--r--spec/frontend/vue_shared/components/registry/details_row_spec.js (renamed from spec/frontend/registry/shared/components/details_row_spec.js)2
-rw-r--r--spec/frontend/vue_shared/components/registry/history_item_spec.js (renamed from spec/frontend/packages/details/components/history_element_spec.js)14
-rw-r--r--spec/frontend/vue_shared/components/registry/list_item_spec.js (renamed from spec/frontend/registry/explorer/components/list_item_spec.js)81
-rw-r--r--spec/frontend/vue_shared/components/registry/metadata_item_spec.js101
-rw-r--r--spec/frontend/vue_shared/components/registry/title_area_spec.js98
-rw-r--r--spec/frontend/vue_shared/components/remove_member_modal_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap739
-rw-r--r--spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js87
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js40
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js25
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js12
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js48
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js24
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js12
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js70
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js18
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js46
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js30
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/table_pagination_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/todo_button_spec.js48
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js106
-rw-r--r--spec/frontend/whats_new/components/app_spec.js24
-rw-r--r--spec/frontend/whats_new/components/trigger_spec.js43
-rw-r--r--spec/frontend_integration/test_helpers/factories/commit.js1
-rw-r--r--spec/frontend_integration/test_helpers/mock_server/graphql.js1
-rw-r--r--spec/frontend_integration/test_helpers/utils/overclock_timers.js1
-rw-r--r--spec/graphql/gitlab_schema_spec.rb4
-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.rb2
-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.rb4
-rw-r--r--spec/graphql/mutations/boards/lists/create_spec.rb15
-rw-r--r--spec/graphql/mutations/discussions/toggle_resolve_spec.rb2
-rw-r--r--spec/graphql/mutations/issues/set_severity_spec.rb62
-rw-r--r--spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb47
-rw-r--r--spec/graphql/resolvers/board_list_issues_resolver_spec.rb36
-rw-r--r--spec/graphql/resolvers/group_members_resolver_spec.rb12
-rw-r--r--spec/graphql/resolvers/issue_status_counts_resolver_spec.rb6
-rw-r--r--spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb7
-rw-r--r--spec/graphql/resolvers/merge_requests_resolver_spec.rb49
-rw-r--r--spec/graphql/resolvers/namespace_projects_resolver_spec.rb51
-rw-r--r--spec/graphql/resolvers/project_members_resolver_spec.rb57
-rw-r--r--spec/graphql/resolvers/project_merge_requests_resolver_spec.rb56
-rw-r--r--spec/graphql/resolvers/project_pipeline_resolver_spec.rb8
-rw-r--r--spec/graphql/resolvers/projects_resolver_spec.rb8
-rw-r--r--spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb13
-rw-r--r--spec/graphql/types/admin/analytics/instance_statistics/measurement_type_spec.rb11
-rw-r--r--spec/graphql/types/base_enum_spec.rb12
-rw-r--r--spec/graphql/types/base_field_spec.rb59
-rw-r--r--spec/graphql/types/boards/board_issue_input_type_spec.rb15
-rw-r--r--spec/graphql/types/current_user_todos_type_spec.rb9
-rw-r--r--spec/graphql/types/design_management/design_type_spec.rb4
-rw-r--r--spec/graphql/types/group_type_spec.rb9
-rw-r--r--spec/graphql/types/issuable_severity_enum_spec.rb13
-rw-r--r--spec/graphql/types/issue_type_spec.rb4
-rw-r--r--spec/graphql/types/member_interface_spec.rb43
-rw-r--r--spec/graphql/types/merge_request_sort_enum_spec.rb15
-rw-r--r--spec/graphql/types/merge_request_type_spec.rb12
-rw-r--r--spec/graphql/types/milestone_type_spec.rb2
-rw-r--r--spec/graphql/types/package_type_enum_spec.rb2
-rw-r--r--spec/graphql/types/permission_types/merge_request_spec.rb3
-rw-r--r--spec/graphql/types/project_type_spec.rb10
-rw-r--r--spec/graphql/types/query_type_spec.rb18
-rw-r--r--spec/graphql/types/release_asset_link_type_spec.rb2
-rw-r--r--spec/graphql/types/user_type_spec.rb1
-rw-r--r--spec/helpers/auth_helper_spec.rb40
-rw-r--r--spec/helpers/blob_helper_spec.rb55
-rw-r--r--spec/helpers/ci/pipelines_helper_spec.rb24
-rw-r--r--spec/helpers/clusters_helper_spec.rb10
-rw-r--r--spec/helpers/container_registry_helper_spec.rb27
-rw-r--r--spec/helpers/dropdowns_helper_spec.rb253
-rw-r--r--spec/helpers/emails_helper_spec.rb72
-rw-r--r--spec/helpers/environments_helper_spec.rb82
-rw-r--r--spec/helpers/groups/group_members_helper_spec.rb48
-rw-r--r--spec/helpers/groups_helper_spec.rb44
-rw-r--r--spec/helpers/issuables_helper_spec.rb4
-rw-r--r--spec/helpers/merge_requests_helper_spec.rb2
-rw-r--r--spec/helpers/notifications_helper_spec.rb12
-rw-r--r--spec/helpers/operations_helper_spec.rb4
-rw-r--r--spec/helpers/releases_helper_spec.rb2
-rw-r--r--spec/helpers/search_helper_spec.rb82
-rw-r--r--spec/helpers/services_helper_spec.rb35
-rw-r--r--spec/helpers/snippets_helper_spec.rb2
-rw-r--r--spec/helpers/storage_helper_spec.rb5
-rw-r--r--spec/helpers/submodule_helper_spec.rb174
-rw-r--r--spec/helpers/tree_helper_spec.rb88
-rw-r--r--spec/helpers/user_callouts_helper_spec.rb20
-rw-r--r--spec/helpers/whats_new_helper_spec.rb22
-rw-r--r--spec/helpers/wiki_helper_spec.rb16
-rw-r--r--spec/initializers/carrierwave_patch_spec.rb12
-rw-r--r--spec/initializers/remove_active_job_execute_callback_spec.rb9
-rw-r--r--spec/lib/api/helpers/packages_manager_clients_helpers_spec.rb127
-rw-r--r--spec/lib/api/helpers_spec.rb58
-rw-r--r--spec/lib/atlassian/jira_connect/client_spec.rb36
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/author_entity_spec.rb23
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb14
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb21
-rw-r--r--spec/lib/atlassian/jira_issue_key_extractor_spec.rb37
-rw-r--r--spec/lib/backup/artifacts_spec.rb37
-rw-r--r--spec/lib/backup/database_spec.rb52
-rw-r--r--spec/lib/backup/files_spec.rb46
-rw-r--r--spec/lib/backup/manager_spec.rb23
-rw-r--r--spec/lib/backup/pages_spec.rb30
-rw-r--r--spec/lib/backup/repository_spec.rb28
-rw-r--r--spec/lib/backup/uploads_spec.rb18
-rw-r--r--spec/lib/banzai/filter/alert_reference_filter_spec.rb223
-rw-r--r--spec/lib/banzai/filter/external_issue_reference_filter_spec.rb43
-rw-r--r--spec/lib/banzai/filter/inline_metrics_filter_spec.rb71
-rw-r--r--spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb9
-rw-r--r--spec/lib/banzai/filter/issue_reference_filter_spec.rb70
-rw-r--r--spec/lib/banzai/filter/table_of_contents_filter_spec.rb5
-rw-r--r--spec/lib/banzai/filter/user_reference_filter_spec.rb16
-rw-r--r--spec/lib/banzai/reference_parser/alert_parser_spec.rb49
-rw-r--r--spec/lib/bitbucket_server/representation/comment_spec.rb25
-rw-r--r--spec/lib/bitbucket_server/representation/pull_request_spec.rb27
-rw-r--r--spec/lib/constraints/jira_encoded_url_constrainer_spec.rb36
-rw-r--r--spec/lib/container_registry/client_spec.rb53
-rw-r--r--spec/lib/gitlab/alert_management/alert_params_spec.rb4
-rw-r--r--spec/lib/gitlab/alert_management/payload/base_spec.rb210
-rw-r--r--spec/lib/gitlab/alert_management/payload/generic_spec.rb89
-rw-r--r--spec/lib/gitlab/alert_management/payload/managed_prometheus_spec.rb167
-rw-r--r--spec/lib/gitlab/alert_management/payload/prometheus_spec.rb240
-rw-r--r--spec/lib/gitlab/alert_management/payload_spec.rb60
-rw-r--r--spec/lib/gitlab/alerting/notification_payload_parser_spec.rb18
-rw-r--r--spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb46
-rw-r--r--spec/lib/gitlab/anonymous_session_spec.rb43
-rw-r--r--spec/lib/gitlab/app_text_logger_spec.rb2
-rw-r--r--spec/lib/gitlab/asciidoc_spec.rb2
-rw-r--r--spec/lib/gitlab/auth/atlassian/auth_hash_spec.rb50
-rw-r--r--spec/lib/gitlab/auth/atlassian/identity_linker_spec.rb71
-rw-r--r--spec/lib/gitlab/auth/atlassian/user_spec.rb60
-rw-r--r--spec/lib/gitlab/auth/ldap/adapter_spec.rb4
-rw-r--r--spec/lib/gitlab/auth/ldap/config_spec.rb4
-rw-r--r--spec/lib/gitlab/auth/o_auth/provider_spec.rb44
-rw-r--r--spec/lib/gitlab/auth/o_auth/user_spec.rb53
-rw-r--r--spec/lib/gitlab/auth_spec.rb23
-rw-r--r--spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/migrate_to_hashed_storage_spec.rb43
-rw-r--r--spec/lib/gitlab/background_migration/set_merge_request_diff_files_count_spec.rb19
-rw-r--r--spec/lib/gitlab/badge/coverage/template_spec.rb4
-rw-r--r--spec/lib/gitlab/badge/pipeline/status_spec.rb28
-rw-r--r--spec/lib/gitlab/badge/pipeline/template_spec.rb4
-rw-r--r--spec/lib/gitlab/bitbucket_server_import/importer_spec.rb356
-rw-r--r--spec/lib/gitlab/checks/lfs_integrity_spec.rb20
-rw-r--r--spec/lib/gitlab/checks/project_moved_spec.rb8
-rw-r--r--spec/lib/gitlab/checks/snippet_check_spec.rb14
-rw-r--r--spec/lib/gitlab/ci/artifact_file_reader_spec.rb11
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/config/entry/jobs_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/root_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/config/normalizer/matrix_strategy_spec.rb21
-rw-r--r--spec/lib/gitlab/ci/config/normalizer_spec.rb26
-rw-r--r--spec/lib/gitlab/ci/config_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/jwt_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/lint_spec.rb251
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb8
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb19
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb74
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/build_spec.rb39
-rw-r--r--spec/lib/gitlab/ci/pipeline_object_hierarchy_spec.rb111
-rw-r--r--spec/lib/gitlab/ci/reports/test_case_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/reports/test_suite_spec.rb31
-rw-r--r--spec/lib/gitlab/ci/status/bridge/common_spec.rb49
-rw-r--r--spec/lib/gitlab/ci/status/composite_spec.rb51
-rw-r--r--spec/lib/gitlab/ci/templates/templates_spec.rb39
-rw-r--r--spec/lib/gitlab/ci/trace/stream_spec.rb22
-rw-r--r--spec/lib/gitlab/ci/trace_spec.rb23
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb806
-rw-r--r--spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb53
-rw-r--r--spec/lib/gitlab/conan_token_spec.rb2
-rw-r--r--spec/lib/gitlab/consul/internal_spec.rb139
-rw-r--r--spec/lib/gitlab/cycle_analytics/code_stage_spec.rb4
-rw-r--r--spec/lib/gitlab/cycle_analytics/events_spec.rb42
-rw-r--r--spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb6
-rw-r--r--spec/lib/gitlab/cycle_analytics/permissions_spec.rb12
-rw-r--r--spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb4
-rw-r--r--spec/lib/gitlab/cycle_analytics/production_stage_spec.rb9
-rw-r--r--spec/lib/gitlab/cycle_analytics/review_stage_spec.rb4
-rw-r--r--spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb2
-rw-r--r--spec/lib/gitlab/cycle_analytics/staging_stage_spec.rb4
-rw-r--r--spec/lib/gitlab/cycle_analytics/test_stage_spec.rb2
-rw-r--r--spec/lib/gitlab/danger/changelog_spec.rb72
-rw-r--r--spec/lib/gitlab/danger/helper_spec.rb40
-rw-r--r--spec/lib/gitlab/danger/teammate_spec.rb57
-rw-r--r--spec/lib/gitlab/data_builder/deployment_spec.rb2
-rw-r--r--spec/lib/gitlab/database/background_migration_job_spec.rb9
-rw-r--r--spec/lib/gitlab/database/batch_count_spec.rb10
-rw-r--r--spec/lib/gitlab/database/concurrent_reindex_spec.rb207
-rw-r--r--spec/lib/gitlab/database/custom_structure_spec.rb1
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb11
-rw-r--r--spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb2
-rw-r--r--spec/lib/gitlab/database/partitioning/partition_monitoring_spec.rb34
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb9
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb147
-rw-r--r--spec/lib/gitlab/database/schema_cleaner_spec.rb4
-rw-r--r--spec/lib/gitlab/database_importers/common_metrics/prometheus_metric_spec.rb2
-rw-r--r--spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb4
-rw-r--r--spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb4
-rw-r--r--spec/lib/gitlab/email/handler/create_issue_handler_spec.rb22
-rw-r--r--spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb24
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb20
-rw-r--r--spec/lib/gitlab/email/receiver_spec.rb6
-rw-r--r--spec/lib/gitlab/error_tracking/processor/grpc_error_processor_spec.rb75
-rw-r--r--spec/lib/gitlab/experimentation_spec.rb13
-rw-r--r--spec/lib/gitlab/external_authorization/access_spec.rb10
-rw-r--r--spec/lib/gitlab/external_authorization/cache_spec.rb4
-rw-r--r--spec/lib/gitlab/file_type_detection_spec.rb39
-rw-r--r--spec/lib/gitlab/git/base_error_spec.rb23
-rw-r--r--spec/lib/gitlab/git/commit_spec.rb8
-rw-r--r--spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/importer/labels_importer_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/importer/repository_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/label_finder_spec.rb8
-rw-r--r--spec/lib/gitlab/github_import/milestone_finder_spec.rb4
-rw-r--r--spec/lib/gitlab/gitpod_spec.rb66
-rw-r--r--spec/lib/gitlab/gl_repository/repo_type_spec.rb14
-rw-r--r--spec/lib/gitlab/gl_repository_spec.rb11
-rw-r--r--spec/lib/gitlab/graphql/docs/renderer_spec.rb48
-rw-r--r--spec/lib/gitlab/graphql/loaders/issuable_loader_spec.rb51
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb25
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb12
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb37
-rw-r--r--spec/lib/gitlab/group_search_results_spec.rb74
-rw-r--r--spec/lib/gitlab/hashed_storage/migrator_spec.rb12
-rw-r--r--spec/lib/gitlab/http_spec.rb11
-rw-r--r--spec/lib/gitlab/i18n/po_linter_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml24
-rw-r--r--spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb6
-rw-r--r--spec/lib/gitlab/import_export/project/tree_saver_spec.rb6
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml1
-rw-r--r--spec/lib/gitlab/incident_management/pager_duty/incident_issue_description_spec.rb3
-rw-r--r--spec/lib/gitlab/jira/dvcs_spec.rb58
-rw-r--r--spec/lib/gitlab/jira/middleware_spec.rb40
-rw-r--r--spec/lib/gitlab/kas_spec.rb61
-rw-r--r--spec/lib/gitlab/kubernetes/cilium_network_policy_spec.rb101
-rw-r--r--spec/lib/gitlab/kubernetes/kube_client_spec.rb2
-rw-r--r--spec/lib/gitlab/kubernetes/network_policy_spec.rb59
-rw-r--r--spec/lib/gitlab/lfs/client_spec.rb148
-rw-r--r--spec/lib/gitlab/log_timestamp_formatter_spec.rb2
-rw-r--r--spec/lib/gitlab/metrics/dashboard/importer_spec.rb55
-rw-r--r--spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb79
-rw-r--r--spec/lib/gitlab/metrics/dashboard/stages/track_panel_type_spec.rb13
-rw-r--r--spec/lib/gitlab/metrics/dashboard/transformers/yml/v1/prometheus_metrics_spec.rb99
-rw-r--r--spec/lib/gitlab/metrics/dashboard/url_spec.rb103
-rw-r--r--spec/lib/gitlab/metrics/dashboard/validator/errors_spec.rb13
-rw-r--r--spec/lib/gitlab/metrics/dashboard/validator_spec.rb52
-rw-r--r--spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb24
-rw-r--r--spec/lib/gitlab/metrics/instrumentation_spec.rb14
-rw-r--r--spec/lib/gitlab/metrics/method_call_spec.rb2
-rw-r--r--spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb94
-rw-r--r--spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb2
-rw-r--r--spec/lib/gitlab/middleware/multipart/handler_for_jwt_params_spec.rb53
-rw-r--r--spec/lib/gitlab/middleware/multipart/handler_spec.rb53
-rw-r--r--spec/lib/gitlab/middleware/multipart_spec.rb313
-rw-r--r--spec/lib/gitlab/middleware/multipart_with_handler_for_jwt_params_spec.rb171
-rw-r--r--spec/lib/gitlab/middleware/multipart_with_handler_spec.rb144
-rw-r--r--spec/lib/gitlab/middleware/same_site_cookies_spec.rb49
-rw-r--r--spec/lib/gitlab/pages/settings_spec.rb32
-rw-r--r--spec/lib/gitlab/pages_transfer_spec.rb137
-rw-r--r--spec/lib/gitlab/phabricator_import/cache/map_spec.rb2
-rw-r--r--spec/lib/gitlab/project_authorizations_spec.rb60
-rw-r--r--spec/lib/gitlab/project_search_results_spec.rb290
-rw-r--r--spec/lib/gitlab/prometheus/internal_spec.rb28
-rw-r--r--spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb2
-rw-r--r--spec/lib/gitlab/prometheus/queries/validate_query_spec.rb4
-rw-r--r--spec/lib/gitlab/prometheus_client_spec.rb32
-rw-r--r--spec/lib/gitlab/quick_actions/substitution_definition_spec.rb20
-rw-r--r--spec/lib/gitlab/reference_counter_spec.rb2
-rw-r--r--spec/lib/gitlab/regex_spec.rb101
-rw-r--r--spec/lib/gitlab/relative_positioning/item_context_spec.rb215
-rw-r--r--spec/lib/gitlab/relative_positioning/mover_spec.rb487
-rw-r--r--spec/lib/gitlab/relative_positioning/range_spec.rb162
-rw-r--r--spec/lib/gitlab/repository_cache_adapter_spec.rb2
-rw-r--r--spec/lib/gitlab/robots_txt/parser_spec.rb71
-rw-r--r--spec/lib/gitlab/search/recent_issues_spec.rb11
-rw-r--r--spec/lib/gitlab/search/recent_merge_requests_spec.rb11
-rw-r--r--spec/lib/gitlab/search_results_spec.rb54
-rw-r--r--spec/lib/gitlab/sidekiq_daemon/memory_killer_spec.rb6
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/none_spec.rb29
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb3
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb4
-rw-r--r--spec/lib/gitlab/sql/except_spec.rb7
-rw-r--r--spec/lib/gitlab/sql/intersect_spec.rb7
-rw-r--r--spec/lib/gitlab/sql/union_spec.rb37
-rw-r--r--spec/lib/gitlab/static_site_editor/config/file_config_spec.rb15
-rw-r--r--spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb (renamed from spec/lib/gitlab/static_site_editor/config_spec.rb)48
-rw-r--r--spec/lib/gitlab/submodule_links_spec.rb58
-rw-r--r--spec/lib/gitlab/template/finders/global_template_finder_spec.rb19
-rw-r--r--spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb6
-rw-r--r--spec/lib/gitlab/tracking/incident_management_spec.rb2
-rw-r--r--spec/lib/gitlab/tracking_spec.rb4
-rw-r--r--spec/lib/gitlab/updated_notes_paginator_spec.rb2
-rw-r--r--spec/lib/gitlab/usage_data/topology_spec.rb110
-rw-r--r--spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb89
-rw-r--r--spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb277
-rw-r--r--spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb111
-rw-r--r--spec/lib/gitlab/usage_data_counters/kubernetes_agent_counter_spec.rb23
-rw-r--r--spec/lib/gitlab/usage_data_counters/redis_counter_spec.rb48
-rw-r--r--spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb (renamed from spec/lib/gitlab/usage_data_counters/track_unique_actions_spec.rb)19
-rw-r--r--spec/lib/gitlab/usage_data_queries_spec.rb41
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb277
-rw-r--r--spec/lib/gitlab/utils/gzip_spec.rb58
-rw-r--r--spec/lib/gitlab/utils/markdown_spec.rb32
-rw-r--r--spec/lib/gitlab/utils/usage_data_spec.rb149
-rw-r--r--spec/lib/gitlab/web_ide/config/entry/global_spec.rb5
-rw-r--r--spec/lib/gitlab/workhorse_spec.rb8
-rw-r--r--spec/lib/gitlab_danger_spec.rb2
-rw-r--r--spec/lib/object_storage/config_spec.rb41
-rw-r--r--spec/lib/object_storage/direct_upload_spec.rb22
-rw-r--r--spec/lib/product_analytics/tracker_spec.rb51
-rw-r--r--spec/lib/uploaded_file_spec.rb433
-rw-r--r--spec/mailers/devise_mailer_spec.rb29
-rw-r--r--spec/mailers/emails/profile_spec.rb22
-rw-r--r--spec/mailers/notify_spec.rb216
-rw-r--r--spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb2
-rw-r--r--spec/migrations/20200122123016_backfill_project_settings_spec.rb2
-rw-r--r--spec/migrations/20200130145430_reschedule_migrate_issue_trackers_data_spec.rb2
-rw-r--r--spec/migrations/20200406102120_backfill_deployment_clusters_from_deployments_spec.rb2
-rw-r--r--spec/migrations/20200703125016_backfill_namespace_settings_spec.rb2
-rw-r--r--spec/migrations/20200811130433_create_missing_vulnerabilities_issue_links_spec.rb160
-rw-r--r--spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb14
-rw-r--r--spec/migrations/backfill_imported_snippet_repositories_spec.rb2
-rw-r--r--spec/migrations/backfill_snippet_repositories_spec.rb2
-rw-r--r--spec/migrations/complete_namespace_settings_migration_spec.rb24
-rw-r--r--spec/migrations/enqueue_reset_merge_status_second_run_spec.rb2
-rw-r--r--spec/migrations/enqueue_reset_merge_status_spec.rb2
-rw-r--r--spec/migrations/ensure_filled_external_diff_store_on_merge_request_diffs_spec.rb40
-rw-r--r--spec/migrations/ensure_target_project_id_is_filled_spec.rb30
-rw-r--r--spec/migrations/fix_projects_without_project_feature_spec.rb2
-rw-r--r--spec/migrations/fix_projects_without_prometheus_services_spec.rb2
-rw-r--r--spec/migrations/fix_wrong_pages_access_level_spec.rb2
-rw-r--r--spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb4
-rw-r--r--spec/migrations/move_limits_from_plans_spec.rb4
-rw-r--r--spec/migrations/schedule_calculate_wiki_sizes_spec.rb12
-rw-r--r--spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb2
-rw-r--r--spec/migrations/schedule_migrate_security_scans_spec.rb4
-rw-r--r--spec/migrations/schedule_pages_metadata_migration_spec.rb2
-rw-r--r--spec/migrations/schedule_populate_merge_request_assignees_table_spec.rb2
-rw-r--r--spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb2
-rw-r--r--spec/migrations/schedule_populate_project_snippet_statistics_spec.rb2
-rw-r--r--spec/migrations/schedule_populate_user_highest_roles_table_spec.rb2
-rw-r--r--spec/migrations/schedule_recalculate_project_authorizations_second_run_spec.rb2
-rw-r--r--spec/migrations/schedule_recalculate_project_authorizations_spec.rb4
-rw-r--r--spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb2
-rw-r--r--spec/migrations/schedule_sync_issuables_state_id_spec.rb2
-rw-r--r--spec/migrations/schedule_sync_issuables_state_id_where_nil_spec.rb2
-rw-r--r--spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb8
-rw-r--r--spec/models/alert_management/alert_spec.rb72
-rw-r--r--spec/models/analytics/instance_statistics/measurement_spec.rb45
-rw-r--r--spec/models/application_record_spec.rb68
-rw-r--r--spec/models/application_setting_spec.rb29
-rw-r--r--spec/models/atlassian/identity_spec.rb34
-rw-r--r--spec/models/audit_event_partitioned_spec.rb13
-rw-r--r--spec/models/authentication_event_spec.rb15
-rw-r--r--spec/models/badges/project_badge_spec.rb4
-rw-r--r--spec/models/blob_viewer/metrics_dashboard_yml_spec.rb253
-rw-r--r--spec/models/board_group_recent_visit_spec.rb2
-rw-r--r--spec/models/board_project_recent_visit_spec.rb2
-rw-r--r--spec/models/ci/bridge_spec.rb1
-rw-r--r--spec/models/ci/build_metadata_spec.rb2
-rw-r--r--spec/models/ci/build_spec.rb145
-rw-r--r--spec/models/ci/build_trace_chunk_spec.rb123
-rw-r--r--spec/models/ci/build_trace_chunks/fog_spec.rb12
-rw-r--r--spec/models/ci/instance_variable_spec.rb6
-rw-r--r--spec/models/ci/job_artifact_spec.rb40
-rw-r--r--spec/models/ci/legacy_stage_spec.rb2
-rw-r--r--spec/models/ci/persistent_ref_spec.rb8
-rw-r--r--spec/models/ci/pipeline_artifact_spec.rb78
-rw-r--r--spec/models/ci/pipeline_spec.rb473
-rw-r--r--spec/models/ci/ref_spec.rb72
-rw-r--r--spec/models/ci/runner_spec.rb6
-rw-r--r--spec/models/ci_platform_metric_spec.rb94
-rw-r--r--spec/models/clusters/agent_spec.rb11
-rw-r--r--spec/models/clusters/applications/prometheus_spec.rb17
-rw-r--r--spec/models/clusters/cluster_spec.rb1
-rw-r--r--spec/models/clusters/kubernetes_namespace_spec.rb3
-rw-r--r--spec/models/commit_range_spec.rb23
-rw-r--r--spec/models/commit_status_spec.rb18
-rw-r--r--spec/models/concerns/ci/artifactable_spec.rb36
-rw-r--r--spec/models/concerns/from_except_spec.rb7
-rw-r--r--spec/models/concerns/from_intersect_spec.rb7
-rw-r--r--spec/models/concerns/from_set_operator_spec.rb20
-rw-r--r--spec/models/concerns/from_union_spec.rb35
-rw-r--r--spec/models/concerns/id_in_ordered_spec.rb33
-rw-r--r--spec/models/concerns/issuable_spec.rb178
-rw-r--r--spec/models/concerns/milestoneable_spec.rb8
-rw-r--r--spec/models/concerns/prometheus_adapter_spec.rb10
-rw-r--r--spec/models/cycle_analytics/issue_spec.rb8
-rw-r--r--spec/models/cycle_analytics/plan_spec.rb6
-rw-r--r--spec/models/cycle_analytics/production_spec.rb53
-rw-r--r--spec/models/deployment_spec.rb18
-rw-r--r--spec/models/design_management/design_collection_spec.rb60
-rw-r--r--spec/models/design_management/design_spec.rb26
-rw-r--r--spec/models/dev_ops_report/metric_spec.rb (renamed from spec/models/dev_ops_score/metric_spec.rb)4
-rw-r--r--spec/models/diff_note_spec.rb4
-rw-r--r--spec/models/draft_note_spec.rb8
-rw-r--r--spec/models/environment_spec.rb8
-rw-r--r--spec/models/environment_status_spec.rb20
-rw-r--r--spec/models/event_spec.rb16
-rw-r--r--spec/models/group_deploy_key_spec.rb21
-rw-r--r--spec/models/group_spec.rb15
-rw-r--r--spec/models/issuable_severity_spec.rb25
-rw-r--r--spec/models/issue_link_spec.rb53
-rw-r--r--spec/models/issue_spec.rb78
-rw-r--r--spec/models/iteration_spec.rb53
-rw-r--r--spec/models/jira_connect_installation_spec.rb45
-rw-r--r--spec/models/jira_connect_subscription_spec.rb15
-rw-r--r--spec/models/member_spec.rb70
-rw-r--r--spec/models/merge_request/metrics_spec.rb11
-rw-r--r--spec/models/merge_request_diff_commit_spec.rb7
-rw-r--r--spec/models/merge_request_diff_file_spec.rb15
-rw-r--r--spec/models/merge_request_diff_spec.rb36
-rw-r--r--spec/models/merge_request_reviewer_spec.rb14
-rw-r--r--spec/models/merge_request_spec.rb382
-rw-r--r--spec/models/metrics/dashboard/annotation_spec.rb2
-rw-r--r--spec/models/milestone_spec.rb10
-rw-r--r--spec/models/namespace/root_storage_statistics_spec.rb3
-rw-r--r--spec/models/namespace_spec.rb187
-rw-r--r--spec/models/note_spec.rb100
-rw-r--r--spec/models/operations/feature_flag_scope_spec.rb391
-rw-r--r--spec/models/operations/feature_flag_spec.rb258
-rw-r--r--spec/models/operations/feature_flags/strategy_spec.rb323
-rw-r--r--spec/models/operations/feature_flags/user_list_spec.rb102
-rw-r--r--spec/models/operations/feature_flags_client_spec.rb21
-rw-r--r--spec/models/packages/package_file_spec.rb6
-rw-r--r--spec/models/packages/package_spec.rb118
-rw-r--r--spec/models/pages/lookup_path_spec.rb61
-rw-r--r--spec/models/pages_deployment_spec.rb21
-rw-r--r--spec/models/pages_domain_spec.rb10
-rw-r--r--spec/models/performance_monitoring/prometheus_dashboard_spec.rb93
-rw-r--r--spec/models/product_analytics_event_spec.rb25
-rw-r--r--spec/models/project_feature_usage_spec.rb59
-rw-r--r--spec/models/project_services/bamboo_service_spec.rb10
-rw-r--r--spec/models/project_services/buildkite_service_spec.rb2
-rw-r--r--spec/models/project_services/chat_message/merge_message_spec.rb69
-rw-r--r--spec/models/project_services/ewm_service_spec.rb61
-rw-r--r--spec/models/project_services/jira_service_spec.rb147
-rw-r--r--spec/models/project_services/packagist_service_spec.rb30
-rw-r--r--spec/models/project_services/pipelines_email_service_spec.rb22
-rw-r--r--spec/models/project_services/teamcity_service_spec.rb10
-rw-r--r--spec/models/project_spec.rb526
-rw-r--r--spec/models/project_statistics_spec.rb19
-rw-r--r--spec/models/project_team_spec.rb95
-rw-r--r--spec/models/project_wiki_spec.rb13
-rw-r--r--spec/models/remote_mirror_spec.rb24
-rw-r--r--spec/models/repository_spec.rb1
-rw-r--r--spec/models/resource_iteration_event_spec.rb17
-rw-r--r--spec/models/resource_label_event_spec.rb8
-rw-r--r--spec/models/resource_state_event_spec.rb28
-rw-r--r--spec/models/service_spec.rb148
-rw-r--r--spec/models/snippet_input_action_spec.rb6
-rw-r--r--spec/models/snippet_repository_spec.rb5
-rw-r--r--spec/models/snippet_spec.rb215
-rw-r--r--spec/models/snippet_statistics_spec.rb2
-rw-r--r--spec/models/terraform/state_spec.rb62
-rw-r--r--spec/models/terraform/state_version_spec.rb76
-rw-r--r--spec/models/user_agent_detail_spec.rb4
-rw-r--r--spec/models/user_interacted_project_spec.rb7
-rw-r--r--spec/models/user_spec.rb188
-rw-r--r--spec/models/x509_certificate_spec.rb18
-rw-r--r--spec/policies/design_management/design_policy_spec.rb11
-rw-r--r--spec/policies/global_policy_spec.rb40
-rw-r--r--spec/policies/group_policy_spec.rb44
-rw-r--r--spec/policies/issuable_policy_spec.rb8
-rw-r--r--spec/policies/metrics/dashboard/annotation_policy_spec.rb26
-rw-r--r--spec/policies/namespace_policy_spec.rb26
-rw-r--r--spec/policies/personal_access_token_policy_spec.rb18
-rw-r--r--spec/policies/project_policy_spec.rb233
-rw-r--r--spec/policies/user_policy_spec.rb20
-rw-r--r--spec/presenters/alert_management/alert_presenter_spec.rb134
-rw-r--r--spec/presenters/alert_management/prometheus_alert_presenter_spec.rb85
-rw-r--r--spec/presenters/ci/build_presenter_spec.rb6
-rw-r--r--spec/presenters/ci/pipeline_artifacts/code_coverage_presenter_spec.rb62
-rw-r--r--spec/presenters/ci/pipeline_presenter_spec.rb2
-rw-r--r--spec/presenters/clusters/cluster_presenter_spec.rb2
-rw-r--r--spec/presenters/dev_ops_report/metric_presenter_spec.rb (renamed from spec/presenters/dev_ops_score/metric_presenter_spec.rb)4
-rw-r--r--spec/presenters/packages/conan/package_presenter_spec.rb127
-rw-r--r--spec/presenters/packages/nuget/search_results_presenter_spec.rb2
-rw-r--r--spec/presenters/projects/prometheus/alert_presenter_spec.rb124
-rw-r--r--spec/requests/api/access_requests_spec.rb2
-rw-r--r--spec/requests/api/boards_spec.rb4
-rw-r--r--spec/requests/api/branches_spec.rb4
-rw-r--r--spec/requests/api/ci/pipelines_spec.rb616
-rw-r--r--spec/requests/api/ci/runner/jobs_artifacts_spec.rb40
-rw-r--r--spec/requests/api/ci/runner/jobs_put_spec.rb61
-rw-r--r--spec/requests/api/commits_spec.rb27
-rw-r--r--spec/requests/api/composer_packages_spec.rb107
-rw-r--r--spec/requests/api/conan_instance_packages_spec.rb152
-rw-r--r--spec/requests/api/conan_packages_spec.rb954
-rw-r--r--spec/requests/api/conan_project_packages_spec.rb152
-rw-r--r--spec/requests/api/generic_packages_spec.rb82
-rw-r--r--spec/requests/api/graphql/boards/board_list_issues_query_spec.rb8
-rw-r--r--spec/requests/api/graphql/current_user_todos_spec.rb81
-rw-r--r--spec/requests/api/graphql/group/group_members_spec.rb67
-rw-r--r--spec/requests/api/graphql/group_query_spec.rb11
-rw-r--r--spec/requests/api/graphql/instance_statistics_measurements_spec.rb21
-rw-r--r--spec/requests/api/graphql/issue/issue_spec.rb126
-rw-r--r--spec/requests/api/graphql/metrics/dashboard_query_spec.rb155
-rw-r--r--spec/requests/api/graphql/mutations/award_emojis/add_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/boards/destroy_spec.rb79
-rw-r--r--spec/requests/api/graphql/mutations/boards/lists/create_spec.rb54
-rw-r--r--spec/requests/api/graphql/mutations/boards/lists/update_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/branches/create_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb51
-rw-r--r--spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb31
-rw-r--r--spec/requests/api/graphql/mutations/ci/pipeline_retry_spec.rb45
-rw-r--r--spec/requests/api/graphql/mutations/commits/create_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/design_management/upload_spec.rb32
-rw-r--r--spec/requests/api/graphql/mutations/discussions/toggle_resolve_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/issues/set_locked_spec.rb7
-rw-r--r--spec/requests/api/graphql/mutations/issues/set_severity_spec.rb57
-rw-r--r--spec/requests/api/graphql/mutations/issues/update_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/create_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/notes/create/image_diff_note_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/notes/destroy_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/notes/update/note_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/snippets/create_spec.rb126
-rw-r--r--spec/requests/api/graphql/mutations/snippets/destroy_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/snippets/update_spec.rb105
-rw-r--r--spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/todos/mark_done_spec.rb11
-rw-r--r--spec/requests/api/graphql/mutations/todos/restore_spec.rb11
-rw-r--r--spec/requests/api/graphql/namespace/projects_spec.rb39
-rw-r--r--spec/requests/api/graphql/project/issue/designs/notes_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/issues_spec.rb148
-rw-r--r--spec/requests/api/graphql/project/merge_request_spec.rb3
-rw-r--r--spec/requests/api/graphql/project/merge_requests_spec.rb46
-rw-r--r--spec/requests/api/graphql/project/release_spec.rb60
-rw-r--r--spec/requests/api/graphql/project/releases_spec.rb19
-rw-r--r--spec/requests/api/graphql/project_query_spec.rb48
-rw-r--r--spec/requests/api/graphql/user/starred_projects_query_spec.rb73
-rw-r--r--spec/requests/api/group_packages_spec.rb2
-rw-r--r--spec/requests/api/helpers_spec.rb2
-rw-r--r--spec/requests/api/internal/base_spec.rb4
-rw-r--r--spec/requests/api/internal/kubernetes_spec.rb124
-rw-r--r--spec/requests/api/issue_links_spec.rb207
-rw-r--r--spec/requests/api/issues/get_group_issues_spec.rb97
-rw-r--r--spec/requests/api/issues/issues_spec.rb45
-rw-r--r--spec/requests/api/jobs_spec.rb317
-rw-r--r--spec/requests/api/maven_packages_spec.rb14
-rw-r--r--spec/requests/api/members_spec.rb8
-rw-r--r--spec/requests/api/merge_request_diffs_spec.rb4
-rw-r--r--spec/requests/api/merge_requests_spec.rb152
-rw-r--r--spec/requests/api/notes_spec.rb4
-rw-r--r--spec/requests/api/nuget_packages_spec.rb12
-rw-r--r--spec/requests/api/oauth_tokens_spec.rb6
-rw-r--r--spec/requests/api/pages/internal_access_spec.rb2
-rw-r--r--spec/requests/api/pages/pages_spec.rb4
-rw-r--r--spec/requests/api/pages/private_access_spec.rb2
-rw-r--r--spec/requests/api/pages/public_access_spec.rb2
-rw-r--r--spec/requests/api/project_milestones_spec.rb6
-rw-r--r--spec/requests/api/project_packages_spec.rb18
-rw-r--r--spec/requests/api/project_snippets_spec.rb54
-rw-r--r--spec/requests/api/projects_spec.rb120
-rw-r--r--spec/requests/api/pypi_packages_spec.rb55
-rw-r--r--spec/requests/api/search_spec.rb89
-rw-r--r--spec/requests/api/settings_spec.rb11
-rw-r--r--spec/requests/api/snippets_spec.rb65
-rw-r--r--spec/requests/api/terraform/state_spec.rb12
-rw-r--r--spec/requests/api/todos_spec.rb23
-rw-r--r--spec/requests/api/usage_data_spec.rb79
-rw-r--r--spec/requests/api/users_spec.rb73
-rw-r--r--spec/requests/api/v3/github_spec.rb516
-rw-r--r--spec/requests/api/variables_spec.rb64
-rw-r--r--spec/requests/git_http_spec.rb2
-rw-r--r--spec/requests/jira_authorizations_spec.rb76
-rw-r--r--spec/requests/jira_routing_spec.rb72
-rw-r--r--spec/requests/lfs_http_spec.rb8
-rw-r--r--spec/requests/profiles/notifications_controller_spec.rb6
-rw-r--r--spec/requests/projects/cycle_analytics_events_spec.rb9
-rw-r--r--spec/requests/projects/issue_links_controller_spec.rb156
-rw-r--r--spec/requests/projects/metrics_dashboard_spec.rb40
-rw-r--r--spec/routing/admin_routing_spec.rb20
-rw-r--r--spec/routing/instance_statistics_routing_spec.rb4
-rw-r--r--spec/routing/routing_spec.rb13
-rw-r--r--spec/rubocop/cop/active_record_association_reload_spec.rb2
-rw-r--r--spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb21
-rw-r--r--spec/rubocop/cop/gitlab/bulk_insert_spec.rb9
-rw-r--r--spec/rubocop/cop/gitlab/except_spec.rb19
-rw-r--r--spec/rubocop/cop/gitlab/intersect_spec.rb19
-rw-r--r--spec/rubocop/cop/gitlab/rails_logger_spec.rb26
-rw-r--r--spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb164
-rw-r--r--spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb205
-rw-r--r--spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb90
-rw-r--r--spec/rubocop/cop/migration/safer_boolean_column_spec.rb8
-rw-r--r--spec/rubocop/cop/migration/update_column_in_batches_spec.rb20
-rw-r--r--spec/rubocop/cop/put_group_routes_under_scope_spec.rb14
-rw-r--r--spec/rubocop/cop/put_project_routes_under_scope_spec.rb14
-rw-r--r--spec/rubocop/cop/rspec/timecop_freeze_spec.rb52
-rw-r--r--spec/rubocop/cop/static_translation_definition_spec.rb25
-rw-r--r--spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb24
-rw-r--r--spec/serializers/analytics_build_entity_spec.rb2
-rw-r--r--spec/serializers/build_details_entity_spec.rb22
-rw-r--r--spec/serializers/ci/lint/job_entity_spec.rb43
-rw-r--r--spec/serializers/ci/lint/result_entity_spec.rb16
-rw-r--r--spec/serializers/ci/lint/result_serializer_spec.rb261
-rw-r--r--spec/serializers/cluster_entity_spec.rb21
-rw-r--r--spec/serializers/cluster_serializer_spec.rb1
-rw-r--r--spec/serializers/diff_file_base_entity_spec.rb53
-rw-r--r--spec/serializers/environment_entity_spec.rb20
-rw-r--r--spec/serializers/environment_status_entity_spec.rb2
-rw-r--r--spec/serializers/fork_namespace_entity_spec.rb9
-rw-r--r--spec/serializers/group_group_link_entity_spec.rb13
-rw-r--r--spec/serializers/group_group_link_serializer_spec.rb13
-rw-r--r--spec/serializers/issue_entity_spec.rb2
-rw-r--r--spec/serializers/issue_serializer_spec.rb5
-rw-r--r--spec/serializers/job_entity_spec.rb16
-rw-r--r--spec/serializers/linked_project_issue_entity_spec.rb23
-rw-r--r--spec/serializers/merge_request_basic_entity_spec.rb27
-rw-r--r--spec/serializers/merge_request_poll_widget_entity_spec.rb18
-rw-r--r--spec/serializers/merge_request_sidebar_extras_entity_spec.rb57
-rw-r--r--spec/serializers/merge_request_widget_entity_spec.rb21
-rw-r--r--spec/serializers/pipeline_details_entity_spec.rb8
-rw-r--r--spec/serializers/pipeline_entity_spec.rb8
-rw-r--r--spec/serializers/pipeline_serializer_spec.rb2
-rw-r--r--spec/serializers/test_case_entity_spec.rb10
-rw-r--r--spec/services/admin/propagate_integration_service_spec.rb43
-rw-r--r--spec/services/admin/propagate_service_template_spec.rb (renamed from spec/services/projects/propagate_service_template_spec.rb)15
-rw-r--r--spec/services/alert_management/create_alert_issue_service_spec.rb26
-rw-r--r--spec/services/alert_management/process_prometheus_alert_service_spec.rb146
-rw-r--r--spec/services/audit_event_service_spec.rb22
-rw-r--r--spec/services/authorized_project_update/project_create_service_spec.rb17
-rw-r--r--spec/services/authorized_project_update/project_group_link_create_service_spec.rb11
-rw-r--r--spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb12
-rw-r--r--spec/services/branches/delete_service_spec.rb15
-rw-r--r--spec/services/ci/cancel_user_pipelines_service_spec.rb12
-rw-r--r--spec/services/ci/create_downstream_pipeline_service_spec.rb (renamed from spec/services/ci/create_cross_project_pipeline_service_spec.rb)72
-rw-r--r--spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb8
-rw-r--r--spec/services/ci/destroy_expired_job_artifacts_service_spec.rb52
-rw-r--r--spec/services/ci/destroy_pipeline_service_spec.rb2
-rw-r--r--spec/services/ci/generate_coverage_reports_service_spec.rb12
-rw-r--r--spec/services/ci/parse_dotenv_artifact_service_spec.rb11
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds_deploy_needs_one_build_and_test.yml9
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure_deploy_needs_one_build_and_test.yml21
-rw-r--r--spec/services/ci/pipelines/create_artifact_service_spec.rb67
-rw-r--r--spec/services/ci/register_job_service_spec.rb28
-rw-r--r--spec/services/ci/retry_build_service_spec.rb32
-rw-r--r--spec/services/ci/retry_pipeline_service_spec.rb14
-rw-r--r--spec/services/ci/update_build_state_service_spec.rb238
-rw-r--r--spec/services/ci/update_runner_service_spec.rb2
-rw-r--r--spec/services/clusters/applications/schedule_update_service_spec.rb2
-rw-r--r--spec/services/clusters/aws/provision_service_spec.rb1
-rw-r--r--spec/services/deployments/after_create_service_spec.rb2
-rw-r--r--spec/services/design_management/move_designs_service_spec.rb26
-rw-r--r--spec/services/error_tracking/list_projects_service_spec.rb2
-rw-r--r--spec/services/event_create_service_spec.rb120
-rw-r--r--spec/services/git/branch_hooks_service_spec.rb2
-rw-r--r--spec/services/git/branch_push_service_spec.rb65
-rw-r--r--spec/services/git/wiki_push_service_spec.rb38
-rw-r--r--spec/services/ide/base_config_service_spec.rb (renamed from spec/services/ci/web_ide_config_service_spec.rb)40
-rw-r--r--spec/services/ide/schemas_config_service_spec.rb53
-rw-r--r--spec/services/ide/terminal_config_service_spec.rb69
-rw-r--r--spec/services/incident_management/create_incident_label_service_spec.rb7
-rw-r--r--spec/services/incident_management/incidents/create_service_spec.rb49
-rw-r--r--spec/services/issuable/common_system_notes_service_spec.rb35
-rw-r--r--spec/services/issue_links/create_service_spec.rb184
-rw-r--r--spec/services/issue_links/destroy_service_spec.rb69
-rw-r--r--spec/services/issue_links/list_service_spec.rb194
-rw-r--r--spec/services/issue_rebalancing_service_spec.rb101
-rw-r--r--spec/services/issues/close_service_spec.rb11
-rw-r--r--spec/services/issues/create_service_spec.rb83
-rw-r--r--spec/services/issues/duplicate_service_spec.rb11
-rw-r--r--spec/services/issues/export_csv_service_spec.rb4
-rw-r--r--spec/services/issues/move_service_spec.rb74
-rw-r--r--spec/services/issues/related_branches_service_spec.rb2
-rw-r--r--spec/services/issues/reopen_service_spec.rb11
-rw-r--r--spec/services/issues/reorder_service_spec.rb34
-rw-r--r--spec/services/issues/resolve_discussions_spec.rb2
-rw-r--r--spec/services/issues/update_service_spec.rb179
-rw-r--r--spec/services/issues/zoom_link_service_spec.rb7
-rw-r--r--spec/services/jira/requests/projects/list_service_spec.rb4
-rw-r--r--spec/services/jira_connect/sync_service_spec.rb62
-rw-r--r--spec/services/jira_connect_subscriptions/create_service_spec.rb48
-rw-r--r--spec/services/lfs/push_service_spec.rb93
-rw-r--r--spec/services/members/destroy_service_spec.rb4
-rw-r--r--spec/services/merge_requests/base_service_spec.rb58
-rw-r--r--spec/services/merge_requests/build_service_spec.rb6
-rw-r--r--spec/services/merge_requests/cleanup_refs_service_spec.rb146
-rw-r--r--spec/services/merge_requests/close_service_spec.rb6
-rw-r--r--spec/services/merge_requests/conflicts/list_service_spec.rb3
-rw-r--r--spec/services/merge_requests/create_pipeline_service_spec.rb25
-rw-r--r--spec/services/merge_requests/create_service_spec.rb83
-rw-r--r--spec/services/merge_requests/delete_non_latest_diffs_service_spec.rb2
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb46
-rw-r--r--spec/services/merge_requests/post_merge_service_spec.rb8
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb6
-rw-r--r--spec/services/merge_requests/update_service_spec.rb65
-rw-r--r--spec/services/metrics/dashboard/gitlab_alert_embed_service_spec.rb3
-rw-r--r--spec/services/note_summary_spec.rb2
-rw-r--r--spec/services/notes/create_service_spec.rb14
-rw-r--r--spec/services/notes/quick_actions_service_spec.rb141
-rw-r--r--spec/services/notification_service_spec.rb390
-rw-r--r--spec/services/packages/composer/create_package_service_spec.rb10
-rw-r--r--spec/services/packages/conan/create_package_service_spec.rb10
-rw-r--r--spec/services/packages/maven/create_package_service_spec.rb4
-rw-r--r--spec/services/packages/npm/create_package_service_spec.rb13
-rw-r--r--spec/services/packages/nuget/create_package_service_spec.rb7
-rw-r--r--spec/services/packages/pypi/create_package_service_spec.rb18
-rw-r--r--spec/services/pages/delete_services_spec.rb38
-rw-r--r--spec/services/product_analytics/build_activity_graph_service_spec.rb33
-rw-r--r--spec/services/projects/after_rename_service_spec.rb46
-rw-r--r--spec/services/projects/alerting/notify_service_spec.rb136
-rw-r--r--spec/services/projects/container_repository/delete_tags_service_spec.rb16
-rw-r--r--spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb51
-rw-r--r--spec/services/projects/destroy_service_spec.rb456
-rw-r--r--spec/services/projects/fork_service_spec.rb38
-rw-r--r--spec/services/projects/hashed_storage/base_attachment_service_spec.rb2
-rw-r--r--spec/services/projects/lfs_pointers/lfs_download_service_spec.rb57
-rw-r--r--spec/services/projects/lfs_pointers/lfs_link_service_spec.rb6
-rw-r--r--spec/services/projects/open_issues_count_service_spec.rb8
-rw-r--r--spec/services/projects/overwrite_project_service_spec.rb2
-rw-r--r--spec/services/projects/prometheus/alerts/notify_service_spec.rb5
-rw-r--r--spec/services/projects/transfer_service_spec.rb27
-rw-r--r--spec/services/projects/unlink_fork_service_spec.rb38
-rw-r--r--spec/services/projects/update_pages_configuration_service_spec.rb9
-rw-r--r--spec/services/projects/update_pages_service_spec.rb4
-rw-r--r--spec/services/projects/update_remote_mirror_service_spec.rb66
-rw-r--r--spec/services/projects/update_service_spec.rb63
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb97
-rw-r--r--spec/services/releases/create_service_spec.rb2
-rw-r--r--spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb23
-rw-r--r--spec/services/snippets/create_service_spec.rb2
-rw-r--r--spec/services/snippets/update_service_spec.rb18
-rw-r--r--spec/services/static_site_editor/config_service_spec.rb64
-rw-r--r--spec/services/submit_usage_ping_service_spec.rb16
-rw-r--r--spec/services/system_note_service_spec.rb44
-rw-r--r--spec/services/system_notes/alert_management_service_spec.rb13
-rw-r--r--spec/services/system_notes/issuables_service_spec.rb90
-rw-r--r--spec/services/task_list_toggle_service_spec.rb8
-rw-r--r--spec/services/todo_service_spec.rb58
-rw-r--r--spec/services/two_factor/destroy_service_spec.rb97
-rw-r--r--spec/services/users/signup_service_spec.rb25
-rw-r--r--spec/services/webauthn/authenticate_service_spec.rb48
-rw-r--r--spec/services/webauthn/register_service_spec.rb36
-rw-r--r--spec/spec_helper.rb27
-rw-r--r--spec/support/factory_default.rb11
-rw-r--r--spec/support/forgery_protection.rb2
-rw-r--r--spec/support/gitlab_stubs/gitlab_ci_for_sast.yml2
-rw-r--r--spec/support/helpers/ci/source_pipeline_helpers.rb13
-rw-r--r--spec/support/helpers/dns_helpers.rb2
-rw-r--r--spec/support/helpers/docs_screenshot_helpers.rb39
-rw-r--r--spec/support/helpers/fake_u2f_device.rb3
-rw-r--r--spec/support/helpers/fake_webauthn_device.rb74
-rw-r--r--spec/support/helpers/feature_flag_helpers.rb95
-rw-r--r--spec/support/helpers/features/editor_lite_spec_helpers.rb29
-rw-r--r--spec/support/helpers/features/releases_helpers.rb117
-rw-r--r--spec/support/helpers/features/snippet_helpers.rb51
-rw-r--r--spec/support/helpers/features/two_factor_helpers.rb74
-rw-r--r--spec/support/helpers/graphql_helpers.rb33
-rw-r--r--spec/support/helpers/jira_service_helper.rb3
-rw-r--r--spec/support/helpers/login_helpers.rb5
-rw-r--r--spec/support/helpers/markdown_feature.rb8
-rw-r--r--spec/support/helpers/metrics_dashboard_helpers.rb2
-rw-r--r--spec/support/helpers/multipart_helpers.rb91
-rw-r--r--spec/support/helpers/navbar_structure_helper.rb4
-rw-r--r--spec/support/helpers/next_found_instance_of.rb33
-rw-r--r--spec/support/helpers/project_forks_helper.rb16
-rw-r--r--spec/support/helpers/repo_helpers.rb2
-rw-r--r--spec/support/helpers/snowplow_helpers.rb53
-rw-r--r--spec/support/helpers/stub_object_storage.rb2
-rw-r--r--spec/support/helpers/stubbed_feature.rb5
-rw-r--r--spec/support/helpers/test_env.rb19
-rw-r--r--spec/support/helpers/usage_data_helpers.rb17
-rw-r--r--spec/support/helpers/wiki_helpers.rb5
-rw-r--r--spec/support/helpers/workhorse_helpers.rb18
-rw-r--r--spec/support/import_export/configuration_helper.rb4
-rw-r--r--spec/support/matchers/markdown_matchers.rb9
-rw-r--r--spec/support/shared_contexts/features/file_uploads_shared_context.rb7
-rw-r--r--spec/support/shared_contexts/finders/users_finder_shared_contexts.rb1
-rw-r--r--spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb106
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb10
-rw-r--r--spec/support/shared_contexts/policies/project_policy_shared_context.rb75
-rw-r--r--spec/support/shared_contexts/project_service_jira_context.rb2
-rw-r--r--spec/support/shared_contexts/project_service_shared_context.rb20
-rw-r--r--spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb75
-rw-r--r--spec/support/shared_contexts/serializers/group_group_link_shared_context.rb17
-rw-r--r--spec/support/shared_contexts/services_shared_context.rb3
-rw-r--r--spec/support/shared_examples/ci/jobs_shared_examples.rb27
-rw-r--r--spec/support/shared_examples/controllers/binary_blob_shared_examples.rb86
-rw-r--r--spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb51
-rw-r--r--spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/controllers/unique_hll_events_examples.rb47
-rw-r--r--spec/support/shared_examples/controllers/unique_visits_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb49
-rw-r--r--spec/support/shared_examples/features/2fa_shared_examples.rb108
-rw-r--r--spec/support/shared_examples/features/editable_merge_request_shared_examples.rb13
-rw-r--r--spec/support/shared_examples/features/error_tracking_shared_example.rb4
-rw-r--r--spec/support/shared_examples/features/file_uploads_shared_examples.rb29
-rw-r--r--spec/support/shared_examples/features/packages_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/graphql/members_shared_examples.rb61
-rw-r--r--spec/support/shared_examples/graphql/mutation_shared_examples.rb7
-rw-r--r--spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb3
-rw-r--r--spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb52
-rw-r--r--spec/support/shared_examples/lib/banzai/reference_parser_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/lib/gitlab/alert_management/payload.rb34
-rw-r--r--spec/support/shared_examples/lib/gitlab/auth/atlassian_identity_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb13
-rw-r--r--spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb145
-rw-r--r--spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb79
-rw-r--r--spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/lib/gitlab/search/recent_items.rb87
-rw-r--r--spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb48
-rw-r--r--spec/support/shared_examples/lib/gitlab/sql/set_operator_shared_examples.rb46
-rw-r--r--spec/support/shared_examples/lib/gitlab/usage_data_counters/incident_management_activity_shared_examples.rb32
-rw-r--r--spec/support/shared_examples/models/chat_slash_commands_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb42
-rw-r--r--spec/support/shared_examples/models/concerns/limitable_shared_examples.rb13
-rw-r--r--spec/support/shared_examples/models/concerns/timebox_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/member_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/models/members_notifications_shared_example.rb2
-rw-r--r--spec/support/shared_examples/models/mentionable_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/relative_positioning_shared_examples.rb419
-rw-r--r--spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb18
-rw-r--r--spec/support/shared_examples/models/throttled_touch_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/update_project_statistics_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/models/wiki_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/models/with_uploads_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/path_extraction_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/policies/project_policy_shared_examples.rb44
-rw-r--r--spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb7
-rw-r--r--spec/support/shared_examples/requests/api/boards_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb24
-rw-r--r--spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb843
-rw-r--r--spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb16
-rw-r--r--spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb39
-rw-r--r--spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/packages_shared_examples.rb85
-rw-r--r--spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/snippets_shared_examples.rb139
-rw-r--r--spec/support/shared_examples/requests/snippet_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/services/alert_management_shared_examples.rb38
-rw-r--r--spec/support/shared_examples/services/common_system_notes_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/services/incident_shared_examples.rb47
-rw-r--r--spec/support/shared_examples/services/issuable_shared_examples.rb31
-rw-r--r--spec/support/shared_examples/services/merge_request_shared_examples.rb62
-rw-r--r--spec/support/shared_examples/services/packages_shared_examples.rb9
-rw-r--r--spec/support/shared_examples/services/snippets_shared_examples.rb17
-rw-r--r--spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb18
-rw-r--r--spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb4
-rw-r--r--spec/support/snowplow.rb22
-rw-r--r--spec/support/test_reports/test_reports_helper.rb4
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb9
-rw-r--r--spec/tasks/gitlab/db_rake_spec.rb26
-rw-r--r--spec/tasks/gitlab/usage_data_rake_spec.rb35
-rw-r--r--spec/uploaders/object_storage_spec.rb76
-rw-r--r--spec/uploaders/terraform/versioned_state_uploader_spec.rb29
-rw-r--r--spec/views/admin/application_settings/_package_registry.html.haml_spec.rb65
-rw-r--r--spec/views/admin/services/index.html.haml_spec.rb30
-rw-r--r--spec/views/admin/sessions/two_factor.html.haml_spec.rb4
-rw-r--r--spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb8
-rw-r--r--spec/views/layouts/nav/sidebar/_instance_statistics.html.haml_spec.rb7
-rw-r--r--spec/views/notify/autodevops_disabled_email.text.erb_spec.rb38
-rw-r--r--spec/views/projects/ci/lints/show.html.haml_spec.rb34
-rw-r--r--spec/views/projects/merge_requests/edit.html.haml_spec.rb1
-rw-r--r--spec/views/projects/pipelines/new.html.haml_spec.rb2
-rw-r--r--spec/views/projects/pipelines/show.html.haml_spec.rb26
-rw-r--r--spec/views/registrations/welcome.html.haml_spec.rb26
-rw-r--r--spec/views/search/_results.html.haml_spec.rb37
-rw-r--r--spec/views/shared/deploy_tokens/_form.html.haml_spec.rb62
-rw-r--r--spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb29
-rw-r--r--spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb54
-rw-r--r--spec/workers/ci/build_trace_chunk_flush_worker_spec.rb31
-rw-r--r--spec/workers/ci/create_cross_project_pipeline_worker_spec.rb4
-rw-r--r--spec/workers/ci/pipelines/create_artifact_worker_spec.rb32
-rw-r--r--spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb26
-rw-r--r--spec/workers/ci_platform_metrics_update_cron_worker_spec.rb15
-rw-r--r--spec/workers/cluster_update_app_worker_spec.rb2
-rw-r--r--spec/workers/create_pipeline_worker_spec.rb4
-rw-r--r--spec/workers/delete_diff_files_worker_spec.rb6
-rw-r--r--spec/workers/deployments/finished_worker_spec.rb12
-rw-r--r--spec/workers/git_garbage_collect_worker_spec.rb21
-rw-r--r--spec/workers/issue_placement_worker_spec.rb132
-rw-r--r--spec/workers/issue_rebalancing_worker_spec.rb39
-rw-r--r--spec/workers/jira_connect/sync_branch_worker_spec.rb65
-rw-r--r--spec/workers/jira_connect/sync_merge_request_worker_spec.rb30
-rw-r--r--spec/workers/merge_request_cleanup_refs_worker_spec.rb30
-rw-r--r--spec/workers/new_issue_worker_spec.rb22
-rw-r--r--spec/workers/new_merge_request_worker_spec.rb16
-rw-r--r--spec/workers/new_note_worker_spec.rb12
-rw-r--r--spec/workers/pages_remove_worker_spec.rb26
-rw-r--r--spec/workers/pages_transfer_worker_spec.rb38
-rw-r--r--spec/workers/pages_update_configuration_worker_spec.rb8
-rw-r--r--spec/workers/partition_creation_worker_spec.rb14
-rw-r--r--spec/workers/personal_access_tokens/expired_notification_worker_spec.rb28
-rw-r--r--spec/workers/pipeline_update_ci_ref_status_worker_service_spec.rb19
-rw-r--r--spec/workers/post_receive_spec.rb6
-rw-r--r--spec/workers/propagate_integration_worker_spec.rb9
-rw-r--r--spec/workers/propagate_service_template_worker_spec.rb2
-rw-r--r--spec/workers/remote_mirror_notification_worker_spec.rb2
-rw-r--r--spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb6
-rw-r--r--spec/workers/repository_fork_worker_spec.rb2
-rw-r--r--spec/workers/repository_update_remote_mirror_worker_spec.rb2
-rw-r--r--spec/workers/run_pipeline_schedule_worker_spec.rb2
1802 files changed, 54678 insertions, 23006 deletions
diff --git a/spec/bin/feature_flag_spec.rb b/spec/bin/feature_flag_spec.rb
index f85b8f22210..41117880f95 100644
--- a/spec/bin/feature_flag_spec.rb
+++ b/spec/bin/feature_flag_spec.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'fast_spec_helper'
+require 'rspec-parameterized'
load File.expand_path('../../bin/feature-flag', __dir__)
@@ -11,25 +12,20 @@ RSpec.describe 'bin/feature-flag' do
let(:argv) { %w[feature-flag-name -t development -g group::memory -i https://url -m http://url] }
let(:options) { FeatureFlagOptionParser.parse(argv) }
let(:creator) { described_class.new(options) }
- let(:existing_flag) { File.join('config', 'feature_flags', 'development', 'existing-feature-flag.yml') }
+ let(:existing_flags) do
+ { 'existing-feature-flag' => File.join('config', 'feature_flags', 'development', 'existing-feature-flag.yml') }
+ end
before do
- # create a dummy feature flag
- FileUtils.mkdir_p(File.dirname(existing_flag))
- File.write(existing_flag, '{}')
+ allow(creator).to receive(:all_feature_flag_names) { existing_flags }
+ allow(creator).to receive(:branch_name) { 'feature-branch' }
+ allow(creator).to receive(:editor) { nil }
# ignore writes
allow(File).to receive(:write).and_return(true)
# ignore stdin
allow($stdin).to receive(:gets).and_raise('EOF')
-
- # ignore Git commands
- allow(creator).to receive(:branch_name) { 'feature-branch' }
- end
-
- after do
- FileUtils.rm_f(existing_flag)
end
subject { creator.execute }
@@ -139,7 +135,7 @@ RSpec.describe 'bin/feature-flag' do
expect($stdin).to receive(:gets).and_return(type)
expect do
expect(described_class.read_type).to eq(:development)
- end.to output(/specify the type/).to_stdout
+ end.to output(/Specify the feature flag type/).to_stdout
end
context 'when invalid type is given' do
@@ -151,7 +147,7 @@ RSpec.describe 'bin/feature-flag' do
expect do
expect { described_class.read_type }.to raise_error(/EOF/)
- end.to output(/specify the type/).to_stdout
+ end.to output(/Specify the feature flag type/).to_stdout
.and output(/Invalid type specified/).to_stderr
end
end
@@ -165,7 +161,7 @@ RSpec.describe 'bin/feature-flag' do
expect($stdin).to receive(:gets).and_return(group)
expect do
expect(described_class.read_group).to eq('group::memory')
- end.to output(/specify the group/).to_stdout
+ end.to output(/Specify the group introducing the feature flag/).to_stdout
end
context 'invalid group given' do
@@ -177,8 +173,8 @@ RSpec.describe 'bin/feature-flag' do
expect do
expect { described_class.read_group }.to raise_error(/EOF/)
- end.to output(/specify the group/).to_stdout
- .and output(/Group needs to include/).to_stderr
+ end.to output(/Specify the group introducing the feature flag/).to_stdout
+ .and output(/The group needs to include/).to_stderr
end
end
end
@@ -190,7 +186,7 @@ RSpec.describe 'bin/feature-flag' do
expect($stdin).to receive(:gets).and_return(url)
expect do
expect(described_class.read_introduced_by_url).to eq('https://merge-request')
- end.to output(/can you paste the URL here/).to_stdout
+ end.to output(/URL of the MR introducing the feature flag/).to_stdout
end
context 'empty URL given' do
@@ -200,7 +196,7 @@ RSpec.describe 'bin/feature-flag' do
expect($stdin).to receive(:gets).and_return(url)
expect do
expect(described_class.read_introduced_by_url).to be_nil
- end.to output(/can you paste the URL here/).to_stdout
+ end.to output(/URL of the MR introducing the feature flag/).to_stdout
end
end
@@ -213,7 +209,7 @@ RSpec.describe 'bin/feature-flag' do
expect do
expect { described_class.read_introduced_by_url }.to raise_error(/EOF/)
- end.to output(/can you paste the URL here/).to_stdout
+ end.to output(/URL of the MR introducing the feature flag/).to_stdout
.and output(/URL needs to start with/).to_stderr
end
end
@@ -227,7 +223,7 @@ RSpec.describe 'bin/feature-flag' do
expect($stdin).to receive(:gets).and_return(url)
expect do
expect(described_class.read_rollout_issue_url(options)).to eq('https://issue')
- end.to output(/Paste URL of `rollout issue` here/).to_stdout
+ end.to output(/URL of the rollout issue/).to_stdout
end
context 'invalid URL given' do
@@ -239,7 +235,7 @@ RSpec.describe 'bin/feature-flag' do
expect do
expect { described_class.read_rollout_issue_url(options) }.to raise_error(/EOF/)
- end.to output(/Paste URL of `rollout issue` here/).to_stdout
+ end.to output(/URL of the rollout issue/).to_stdout
.and output(/URL needs to start/).to_stderr
end
end
diff --git a/spec/controllers/admin/cohorts_controller_spec.rb b/spec/controllers/admin/cohorts_controller_spec.rb
new file mode 100644
index 00000000000..9eb2a713517
--- /dev/null
+++ b/spec/controllers/admin/cohorts_controller_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::CohortsController do
+ context 'as admin' do
+ let(:user) { create(:admin) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'renders 200' do
+ get :index
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+
+ describe 'GET #index' do
+ it_behaves_like 'tracking unique visits', :index do
+ let(:target_id) { 'i_analytics_cohorts' }
+ end
+ end
+ end
+
+ context 'as normal user' do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'renders a 404' do
+ get :index
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/controllers/admin/dev_ops_report_controller_spec.rb b/spec/controllers/admin/dev_ops_report_controller_spec.rb
new file mode 100644
index 00000000000..0be30fff0c2
--- /dev/null
+++ b/spec/controllers/admin/dev_ops_report_controller_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::DevOpsReportController do
+ describe 'GET #show' do
+ context 'as admin' do
+ let(:user) { create(:admin) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'responds with success' do
+ get :show
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+
+ it_behaves_like 'tracking unique visits', :show do
+ let(:target_id) { 'i_analytics_dev_ops_score' }
+ end
+ end
+ end
+
+ context 'as normal user' do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'responds with 404' do
+ get :show
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/controllers/admin/instance_statistics_controller_spec.rb b/spec/controllers/admin/instance_statistics_controller_spec.rb
new file mode 100644
index 00000000000..c589e46857f
--- /dev/null
+++ b/spec/controllers/admin/instance_statistics_controller_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::InstanceStatisticsController do
+ let(:admin) { create(:user, :admin) }
+
+ before do
+ sign_in(admin)
+ end
+
+ describe 'GET #show' do
+ it_behaves_like 'tracking unique visits', :index do
+ let(:target_id) { 'i_analytics_instance_statistics' }
+ end
+ end
+end
diff --git a/spec/controllers/admin/integrations_controller_spec.rb b/spec/controllers/admin/integrations_controller_spec.rb
index 4a5d5ede728..4b1806a43d2 100644
--- a/spec/controllers/admin/integrations_controller_spec.rb
+++ b/spec/controllers/admin/integrations_controller_spec.rb
@@ -23,12 +23,15 @@ RSpec.describe Admin::IntegrationsController do
end
describe '#update' do
+ include JiraServiceHelper
+
let(:integration) { create(:jira_service, :instance) }
before do
+ stub_jira_service_test
allow(PropagateIntegrationWorker).to receive(:perform_async)
- put :update, params: { id: integration.class.to_param, overwrite: true, service: { url: url } }
+ put :update, params: { id: integration.class.to_param, service: { url: url } }
end
context 'valid params' do
@@ -40,7 +43,7 @@ RSpec.describe Admin::IntegrationsController do
end
it 'calls to PropagateIntegrationWorker' do
- expect(PropagateIntegrationWorker).to have_received(:perform_async).with(integration.id, true)
+ expect(PropagateIntegrationWorker).to have_received(:perform_async).with(integration.id, false)
end
end
diff --git a/spec/controllers/admin/plan_limits_controller_spec.rb b/spec/controllers/admin/plan_limits_controller_spec.rb
new file mode 100644
index 00000000000..2666925c2b7
--- /dev/null
+++ b/spec/controllers/admin/plan_limits_controller_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::PlanLimitsController do
+ let_it_be(:plan) { create(:plan) }
+ let_it_be(:plan_limits) { create(:plan_limits, plan: plan) }
+
+ describe 'POST create' do
+ let(:params) do
+ {
+ plan_limits: {
+ plan_id: plan.id,
+ conan_max_file_size: file_size, id: plan_limits.id
+ }
+ }
+ end
+
+ context 'with an authenticated admin user' do
+ let(:file_size) { 10.megabytes }
+
+ it 'updates the plan limits', :aggregate_failures do
+ sign_in(create(:admin))
+
+ post :create, params: params
+
+ expect(response).to redirect_to(general_admin_application_settings_path)
+ expect(plan_limits.reload.conan_max_file_size).to eq(file_size)
+ end
+ end
+
+ context 'without admin access' do
+ let(:file_size) { 1.megabytes }
+
+ it 'returns `not_found`' do
+ sign_in(create(:user))
+
+ post :create, params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(plan_limits.conan_max_file_size).not_to eq(file_size)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/admin/sessions_controller_spec.rb b/spec/controllers/admin/sessions_controller_spec.rb
index 82366cc6952..35982e57034 100644
--- a/spec/controllers/admin/sessions_controller_spec.rb
+++ b/spec/controllers/admin/sessions_controller_spec.rb
@@ -220,10 +220,8 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
end
end
- context 'when using two-factor authentication via U2F' do
- let(:user) { create(:admin, :two_factor_via_u2f) }
-
- def authenticate_2fa_u2f(user_params)
+ shared_examples 'when using two-factor authentication via hardware device' do
+ def authenticate_2fa(user_params)
post(:create, params: { user: user_params }, session: { otp_user_id: user.id })
end
@@ -239,14 +237,18 @@ 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)
controller.store_location_for(:redirect, admin_root_path)
controller.current_user_mode.request_admin_mode!
- authenticate_2fa_u2f(login: user.username, device_response: '{}')
+ authenticate_2fa(login: user.username, device_response: '{}')
expect(response).to redirect_to admin_root_path
expect(controller.current_user_mode.admin_mode?).to be(true)
@@ -254,16 +256,33 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
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)
controller.current_user_mode.request_admin_mode!
- authenticate_2fa_u2f(login: user.username, device_response: '{}')
+ authenticate_2fa(login: user.username, device_response: '{}')
expect(response).to render_template('admin/sessions/two_factor')
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/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 08a1d7c9fa9..e4cdcda756b 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -218,28 +218,44 @@ RSpec.describe Admin::UsersController do
end
describe 'PATCH disable_two_factor' do
- it 'disables 2FA for the user' do
- expect(user).to receive(:disable_two_factor!)
- allow(subject).to receive(:user).and_return(user)
+ subject { patch :disable_two_factor, params: { id: user.to_param } }
- go
- end
+ context 'for a user that has 2FA enabled' do
+ let(:user) { create(:user, :two_factor) }
- it 'redirects back' do
- go
+ it 'disables 2FA for the user' do
+ subject
- expect(response).to redirect_to(admin_user_path(user))
- end
+ expect(user.reload.two_factor_enabled?).to eq(false)
+ end
+
+ it 'redirects back' do
+ subject
+
+ expect(response).to redirect_to(admin_user_path(user))
+ end
- it 'displays an alert' do
- go
+ it 'displays a notice on success' do
+ subject
- expect(flash[:notice])
- .to eq _('Two-factor Authentication has been disabled for this user')
+ expect(flash[:notice])
+ .to eq _('Two-factor authentication has been disabled for this user')
+ end
end
- def go
- patch :disable_two_factor, params: { id: user.to_param }
+ context 'for a user that does not have 2FA enabled' do
+ it 'redirects back' do
+ subject
+
+ expect(response).to redirect_to(admin_user_path(user))
+ end
+
+ it 'displays an alert on failure' do
+ subject
+
+ expect(flash[:alert])
+ .to eq _('Two-factor authentication is not enabled for this user')
+ end
end
end
@@ -270,82 +286,111 @@ RSpec.describe Admin::UsersController do
describe 'POST update' do
context 'when the password has changed' do
- def update_password(user, password, password_confirmation = nil)
+ def update_password(user, password = User.random_password, password_confirmation = password)
params = {
id: user.to_param,
user: {
password: password,
- password_confirmation: password_confirmation || password
+ password_confirmation: password_confirmation
}
}
post :update, params: params
end
- context 'when the admin changes their own password' do
- it 'updates the password' do
- expect { update_password(admin, 'AValidPassword1') }
- .to change { admin.reload.encrypted_password }
- end
-
- it 'does not set the new password to expire immediately' do
- expect { update_password(admin, 'AValidPassword1') }
- .not_to change { admin.reload.password_expires_at }
+ context 'when admin changes their own password' do
+ context 'when password is valid' do
+ it 'updates the password' do
+ expect { update_password(admin) }
+ .to change { admin.reload.encrypted_password }
+ end
+
+ it 'does not set the new password to expire immediately' do
+ expect { update_password(admin) }
+ .not_to change { admin.reload.password_expired? }
+ end
+
+ it 'does not enqueue the `admin changed your password` email' do
+ expect { update_password(admin) }
+ .not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+
+ it 'enqueues the `password changed` email' do
+ expect { update_password(admin) }
+ .to have_enqueued_mail(DeviseMailer, :password_change)
+ end
end
end
- context 'when the new password is valid' do
- it 'redirects to the user' do
- update_password(user, 'AValidPassword1')
-
- expect(response).to redirect_to(admin_user_path(user))
- end
-
- it 'updates the password' do
- expect { update_password(user, 'AValidPassword1') }
- .to change { user.reload.encrypted_password }
- end
-
- it 'sets the new password to expire immediately' do
- expect { update_password(user, 'AValidPassword1') }
- .to change { user.reload.password_expires_at }.to be_within(2.seconds).of(Time.current)
+ context 'when admin changes the password of another user' do
+ context 'when the new password is valid' do
+ it 'redirects to the user' do
+ update_password(user)
+
+ expect(response).to redirect_to(admin_user_path(user))
+ end
+
+ it 'updates the password' do
+ expect { update_password(user) }
+ .to change { user.reload.encrypted_password }
+ end
+
+ it 'sets the new password to expire immediately' do
+ expect { update_password(user) }
+ .to change { user.reload.password_expired? }.from(false).to(true)
+ end
+
+ it 'enqueues the `admin changed your password` email' do
+ expect { update_password(user) }
+ .to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+
+ it 'does not enqueue the `password changed` email' do
+ expect { update_password(user) }
+ .not_to have_enqueued_mail(DeviseMailer, :password_change)
+ end
end
end
context 'when the new password is invalid' do
+ let(:password) { 'invalid' }
+
it 'shows the edit page again' do
- update_password(user, 'invalid')
+ update_password(user, password)
expect(response).to render_template(:edit)
end
it 'returns the error message' do
- update_password(user, 'invalid')
+ update_password(user, password)
expect(assigns[:user].errors).to contain_exactly(a_string_matching(/too short/))
end
it 'does not update the password' do
- expect { update_password(user, 'invalid') }
+ expect { update_password(user, password) }
.not_to change { user.reload.encrypted_password }
end
end
context 'when the new password does not match the password confirmation' do
+ let(:password) { 'some_password' }
+ let(:password_confirmation) { 'not_same_as_password' }
+
it 'shows the edit page again' do
- update_password(user, 'AValidPassword1', 'AValidPassword2')
+ update_password(user, password, password_confirmation)
expect(response).to render_template(:edit)
end
it 'returns the error message' do
- update_password(user, 'AValidPassword1', 'AValidPassword2')
+ update_password(user, password, password_confirmation)
expect(assigns[:user].errors).to contain_exactly(a_string_matching(/doesn't match/))
end
it 'does not update the password' do
- expect { update_password(user, 'AValidPassword1', 'AValidPassword2') }
+ expect { update_password(user, password, password_confirmation) }
.not_to change { user.reload.encrypted_password }
end
end
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index f8d4690e9ce..188a4cb04af 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -795,13 +795,8 @@ RSpec.describe ApplicationController do
end
let(:user) { create(:user) }
- let(:experiment_enabled) { true }
- before do
- stub_experiment_for_user(signup_flow: experiment_enabled)
- end
-
- context 'experiment enabled and user with required role' do
+ context 'user with required role' do
before do
user.set_role_required!
sign_in(user)
@@ -811,7 +806,7 @@ RSpec.describe ApplicationController do
it { is_expected.to redirect_to users_sign_up_welcome_path }
end
- context 'experiment enabled and user without a required role' do
+ context 'user without a required role' do
before do
sign_in(user)
get :index
@@ -819,43 +814,31 @@ RSpec.describe ApplicationController do
it { is_expected.not_to redirect_to users_sign_up_welcome_path }
end
+ end
- context 'experiment disabled' do
- let(:experiment_enabled) { false }
+ describe 'rescue_from Gitlab::Auth::IpBlacklisted' do
+ controller(described_class) do
+ skip_before_action :authenticate_user!
- before do
- user.set_role_required!
- sign_in(user)
- get :index
+ def index
+ raise Gitlab::Auth::IpBlacklisted
end
-
- it { is_expected.not_to redirect_to users_sign_up_welcome_path }
end
- describe 'rescue_from Gitlab::Auth::IpBlacklisted' do
- controller(described_class) do
- skip_before_action :authenticate_user!
-
- def index
- raise Gitlab::Auth::IpBlacklisted
- end
- end
-
- it 'returns a 403 and logs the request' do
- expect(Gitlab::AuthLogger).to receive(:error).with({
- message: 'Rack_Attack',
- env: :blocklist,
- remote_ip: '1.2.3.4',
- request_method: 'GET',
- path: '/anonymous'
- })
+ it 'returns a 403 and logs the request' do
+ expect(Gitlab::AuthLogger).to receive(:error).with({
+ message: 'Rack_Attack',
+ env: :blocklist,
+ remote_ip: '1.2.3.4',
+ request_method: 'GET',
+ path: '/anonymous'
+ })
- request.remote_addr = '1.2.3.4'
+ request.remote_addr = '1.2.3.4'
- get :index
+ get :index
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ expect(response).to have_gitlab_http_status(:forbidden)
end
end
diff --git a/spec/controllers/concerns/redis_tracking_spec.rb b/spec/controllers/concerns/redis_tracking_spec.rb
new file mode 100644
index 00000000000..3795fca5576
--- /dev/null
+++ b/spec/controllers/concerns/redis_tracking_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe RedisTracking do
+ let(:event_name) { 'g_compliance_dashboard' }
+ let(:feature) { 'g_compliance_dashboard_feature' }
+ let(:user) { create(:user) }
+
+ controller(ApplicationController) do
+ include RedisTracking
+
+ skip_before_action :authenticate_user!, only: :show
+ track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score', feature: :g_compliance_dashboard_feature, feature_default_enabled: true
+
+ def index
+ render html: 'index'
+ end
+
+ def new
+ render html: 'new'
+ end
+
+ def show
+ render html: 'show'
+ end
+ end
+
+ context 'with feature disabled' do
+ it 'does not track the event' do
+ stub_feature_flags(feature => false)
+
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ get :index
+ end
+ end
+
+ context 'with usage ping disabled' do
+ it 'does not track the event' do
+ stub_feature_flags(feature => true)
+ allow(Gitlab::CurrentSettings).to receive(:usage_ping_enabled?).and_return(false)
+
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ get :index
+ end
+ end
+
+ context 'with feature enabled and usage ping enabled' do
+ before do
+ stub_feature_flags(feature => true)
+ allow(Gitlab::CurrentSettings).to receive(:usage_ping_enabled?).and_return(true)
+ end
+
+ context 'when user is logged in' do
+ it 'tracks the event' do
+ sign_in(user)
+
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
+
+ get :index
+ end
+
+ it 'passes default_enabled flag' do
+ sign_in(user)
+
+ expect(controller).to receive(:metric_feature_enabled?).with(feature.to_sym, true)
+
+ get :index
+ end
+ end
+
+ context 'when user is not logged in and there is a visitor_id' do
+ let(:visitor_id) { SecureRandom.uuid }
+
+ before do
+ routes.draw { get 'show' => 'anonymous#show' }
+ end
+
+ it 'tracks the event' do
+ cookies[:visitor_id] = { value: visitor_id, expires: 24.months }
+
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
+
+ get :show
+ end
+ end
+
+ context 'when user is not logged in and there is no visitor_id' do
+ it 'does not tracks the event' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ get :index
+ end
+ end
+
+ context 'for untracked action' do
+ it 'does not tracks the event' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ get :new
+ end
+ end
+ end
+end
diff --git a/spec/controllers/concerns/send_file_upload_spec.rb b/spec/controllers/concerns/send_file_upload_spec.rb
index e24e4cbf5e7..747ccd7ba1b 100644
--- a/spec/controllers/concerns/send_file_upload_spec.rb
+++ b/spec/controllers/concerns/send_file_upload_spec.rb
@@ -50,9 +50,14 @@ RSpec.describe SendFileUpload do
shared_examples 'handles image resize requests' do
let(:headers) { double }
+ let(:image_requester) { build(:user) }
+ let(:image_owner) { build(:user) }
+ let(:params) do
+ { attachment: 'avatar.png' }
+ end
before do
- allow(uploader).to receive(:image?).and_return(true)
+ allow(uploader).to receive(:image_safe_for_scaling?).and_return(true)
allow(uploader).to receive(:mounted_as).and_return(:avatar)
allow(controller).to receive(:headers).and_return(headers)
@@ -60,70 +65,127 @@ RSpec.describe SendFileUpload do
# local or remote files
allow(controller).to receive(:send_file)
allow(controller).to receive(:redirect_to)
+
+ allow(controller).to receive(:current_user).and_return(image_requester)
+ allow(uploader).to receive(:model).and_return(image_owner)
end
- context 'when feature is enabled for current user' do
- let(:user) { build(:user) }
+ context 'when boths FFs are enabled' do
+ before do
+ stub_feature_flags(dynamic_image_resizing_requester: image_requester)
+ stub_feature_flags(dynamic_image_resizing_owner: image_owner)
+ end
+
+ it_behaves_like 'handles image resize requests allowed by FFs'
+ end
+ context 'when boths FFs are enabled globally' do
before do
- stub_feature_flags(dynamic_image_resizing: user)
- allow(controller).to receive(:current_user).and_return(user)
+ stub_feature_flags(dynamic_image_resizing_requester: true)
+ stub_feature_flags(dynamic_image_resizing_owner: true)
end
- context 'with valid width parameter' do
- it 'renders OK with workhorse command header' do
- expect(controller).not_to receive(:send_file)
- expect(controller).to receive(:params).at_least(:once).and_return(width: '64')
- expect(controller).to receive(:head).with(:ok)
- expect(headers).to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
+ it_behaves_like 'handles image resize requests allowed by FFs'
- subject
+ context 'when current_user is nil' do
+ before do
+ allow(controller).to receive(:current_user).and_return(nil)
end
+
+ it_behaves_like 'handles image resize requests allowed by FFs'
end
+ end
- context 'with missing width parameter' do
- it 'does not write workhorse command header' do
- expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
+ context 'when only FF based on content requester is enabled for current user' do
+ before do
+ stub_feature_flags(dynamic_image_resizing_requester: image_requester)
+ stub_feature_flags(dynamic_image_resizing_owner: false)
+ end
- subject
- end
+ it_behaves_like 'bypasses image resize requests not allowed by FFs'
+ end
+
+ context 'when only FF based on content owner is enabled for requested avatar owner' do
+ before do
+ stub_feature_flags(dynamic_image_resizing_requester: false)
+ stub_feature_flags(dynamic_image_resizing_owner: image_owner)
end
- context 'with invalid width parameter' do
- it 'does not write workhorse command header' do
- expect(controller).to receive(:params).at_least(:once).and_return(width: 'not a number')
- expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
+ it_behaves_like 'bypasses image resize requests not allowed by FFs'
+ end
- subject
- end
+ context 'when both FFs are disabled' do
+ before do
+ stub_feature_flags(dynamic_image_resizing_requester: false)
+ stub_feature_flags(dynamic_image_resizing_owner: false)
end
- context 'with width that is not allowed' do
- it 'does not write workhorse command header' do
- expect(controller).to receive(:params).at_least(:once).and_return(width: '63')
- expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
+ it_behaves_like 'bypasses image resize requests not allowed by FFs'
+ end
+ end
- subject
- end
+ shared_examples 'bypasses image resize requests not allowed by FFs' 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 FFs' do
+ context 'with valid width parameter' do
+ it 'renders OK with workhorse command header' do
+ expect(controller).not_to receive(:send_file)
+ expect(controller).to receive(:params).at_least(:once).and_return(width: '64')
+ expect(controller).to receive(:head).with(:ok)
+
+ expect(Gitlab::Workhorse).to receive(:send_scaled_image).with(a_string_matching('^(/.+|https://.+)'), 64, 'image/png').and_return([
+ Gitlab::Workhorse::SEND_DATA_HEADER, "send-scaled-img:faux"
+ ])
+ expect(headers).to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, "send-scaled-img:faux")
+
+ subject
end
+ end
- context 'when image file is not an avatar' do
- it 'does not write workhorse command header' do
- expect(uploader).to receive(:mounted_as).and_return(nil) # FileUploader is not mounted
- expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
+ context 'with missing width parameter' 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
+ subject
end
end
- context 'when feature is disabled' do
- before do
- stub_feature_flags(dynamic_image_resizing: false)
+ context 'with invalid width parameter' do
+ it 'does not write workhorse command header' do
+ expect(controller).to receive(:params).at_least(:once).and_return(width: 'not a number')
+ expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
+
+ subject
end
+ end
+ context 'with width that is not allowed' do
it 'does not write workhorse command header' do
- expect(controller).to receive(:params).at_least(:once).and_return(width: '64')
+ expect(controller).to receive(:params).at_least(:once).and_return(width: '63')
+ expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
+
+ subject
+ end
+ end
+
+ context 'when image file is not an avatar' do
+ it 'does not write workhorse command header' do
+ expect(uploader).to receive(:mounted_as).and_return(nil) # FileUploader is not mounted
+ expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
+
+ subject
+ end
+ end
+
+ context 'when image file type is not considered safe for scaling' do
+ it 'does not write workhorse command header' do
+ expect(uploader).to receive(:image_safe_for_scaling?).and_return(false)
expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
subject
diff --git a/spec/controllers/dashboard/projects_controller_spec.rb b/spec/controllers/dashboard/projects_controller_spec.rb
index 1e1d9519f78..2719b7c8a24 100644
--- a/spec/controllers/dashboard/projects_controller_spec.rb
+++ b/spec/controllers/dashboard/projects_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Dashboard::ProjectsController do
+RSpec.describe Dashboard::ProjectsController, :aggregate_failures do
include ExternalAuthorizationServiceHelpers
let_it_be(:user) { create(:user) }
@@ -15,6 +15,7 @@ RSpec.describe Dashboard::ProjectsController do
context 'user logged in' do
let_it_be(:project) { create(:project) }
let_it_be(:project2) { create(:project) }
+ let(:projects) { [project, project2] }
before_all do
project.add_developer(user)
@@ -41,7 +42,7 @@ RSpec.describe Dashboard::ProjectsController do
get :index
- expect(assigns(:projects)).to eq([project, project2])
+ expect(assigns(:projects)).to eq(projects)
end
context 'project sorting' do
@@ -66,6 +67,20 @@ RSpec.describe Dashboard::ProjectsController do
it_behaves_like 'search and sort parameters', sort
end
end
+
+ context 'with deleted project' do
+ let!(:pending_delete_project) do
+ project.tap { |p| p.update!(pending_delete: true) }
+ end
+
+ it 'does not display deleted project' do
+ get :index
+ projects_result = assigns(:projects)
+
+ expect(projects_result).not_to include(pending_delete_project)
+ expect(projects_result).to include(project2)
+ end
+ end
end
end
@@ -153,7 +168,7 @@ RSpec.describe Dashboard::ProjectsController do
project.add_developer(user)
end
- it 'renders all kinds of event without error', :aggregate_failures do
+ it 'renders all kinds of event without error' do
get :index, format: :atom
expect(assigns(:events)).to include(design_event, wiki_page_event, issue_event)
@@ -165,6 +180,21 @@ RSpec.describe Dashboard::ProjectsController do
"closed issue #{issue.to_reference}"
)
end
+
+ context 'with deleted project' do
+ let(:pending_deleted_project) { projects.last.tap { |p| p.update!(pending_delete: true) } }
+
+ before do
+ pending_deleted_project.add_developer(user)
+ end
+
+ it 'does not display deleted project' do
+ get :index, format: :atom
+
+ expect(response.body).not_to include(pending_deleted_project.full_name)
+ expect(response.body).to include(project.full_name)
+ end
+ end
end
end
end
diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb
index c5643f96b7a..405f1eae482 100644
--- a/spec/controllers/graphql_controller_spec.rb
+++ b/spec/controllers/graphql_controller_spec.rb
@@ -60,14 +60,28 @@ RSpec.describe GraphqlController do
it 'updates the users last_activity_on field' do
expect { post :execute }.to change { user.reload.last_activity_on }
end
+
+ it "sets context's sessionless value as false" do
+ post :execute
+
+ expect(assigns(:context)[:is_sessionless_user]).to be false
+ end
end
context 'when user uses an API token' do
let(:user) { create(:user, last_activity_on: Date.yesterday) }
let(:token) { create(:personal_access_token, user: user, scopes: [:api]) }
+ subject { post :execute, params: { access_token: token.token } }
+
it 'updates the users last_activity_on field' do
- expect { post :execute, params: { access_token: token.token } }.to change { user.reload.last_activity_on }
+ expect { subject }.to change { user.reload.last_activity_on }
+ end
+
+ it "sets context's sessionless value as true" do
+ subject
+
+ expect(assigns(:context)[:is_sessionless_user]).to be true
end
end
@@ -77,6 +91,12 @@ RSpec.describe GraphqlController do
expect(response).to have_gitlab_http_status(:ok)
end
+
+ it "sets context's sessionless value as false" do
+ post :execute
+
+ expect(assigns(:context)[:is_sessionless_user]).to be false
+ end
end
end
@@ -127,4 +147,22 @@ RSpec.describe GraphqlController do
end
end
end
+
+ describe '#append_info_to_payload' do
+ let(:graphql_query) { graphql_query_for('project', { 'fullPath' => 'foo' }, %w(id name)) }
+ let(:log_payload) { {} }
+
+ before do
+ allow(controller).to receive(:append_info_to_payload).and_wrap_original do |method, *|
+ method.call(log_payload)
+ end
+ end
+
+ it 'appends metadata for logging' do
+ post :execute, params: { query: graphql_query, operationName: 'Foo' }
+
+ expect(controller).to have_received(:append_info_to_payload)
+ expect(log_payload.dig(:metadata, :graphql, :operation_name)).to eq('Foo')
+ end
+ end
end
diff --git a/spec/controllers/groups/settings/integrations_controller_spec.rb b/spec/controllers/groups/settings/integrations_controller_spec.rb
index d079f3f077e..cdcdfde175f 100644
--- a/spec/controllers/groups/settings/integrations_controller_spec.rb
+++ b/spec/controllers/groups/settings/integrations_controller_spec.rb
@@ -3,7 +3,6 @@
require 'spec_helper'
RSpec.describe Groups::Settings::IntegrationsController do
- let_it_be(:project) { create(:project) }
let(:user) { create(:user) }
let(:group) { create(:group) }
@@ -82,10 +81,13 @@ RSpec.describe Groups::Settings::IntegrationsController do
end
describe '#update' do
- let(:integration) { create(:jira_service, project: project) }
+ include JiraServiceHelper
+
+ let(:integration) { create(:jira_service, project: nil, group_id: group.id) }
before do
group.add_owner(user)
+ stub_jira_service_test
put :update, params: { group_id: group, id: integration.class.to_param, service: { url: url } }
end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index 78de89de796..35d8c0b7c6d 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -422,10 +422,6 @@ RSpec.describe GroupsController do
end
context 'searching' do
- before do
- stub_feature_flags(attempt_group_search_optimizations: true)
- end
-
it 'works with popularity sort' do
get :issues, params: { id: group.to_param, search: 'foo', sort: 'popularity' }
diff --git a/spec/controllers/instance_statistics/cohorts_controller_spec.rb b/spec/controllers/instance_statistics/cohorts_controller_spec.rb
deleted file mode 100644
index c16ac0dced1..00000000000
--- a/spec/controllers/instance_statistics/cohorts_controller_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe InstanceStatistics::CohortsController do
- let(:user) { create(:user) }
-
- before do
- sign_in(user)
- end
-
- it_behaves_like 'instance statistics availability'
-
- it 'renders a 404 when the usage ping is disabled' do
- stub_application_setting(usage_ping_enabled: false)
-
- get :index
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
- describe 'GET #index' do
- it_behaves_like 'tracking unique visits', :index do
- let(:request_params) { {} }
- let(:target_id) { 'i_analytics_cohorts' }
- end
- end
-end
diff --git a/spec/controllers/instance_statistics/dev_ops_score_controller_spec.rb b/spec/controllers/instance_statistics/dev_ops_score_controller_spec.rb
deleted file mode 100644
index c9677a64eef..00000000000
--- a/spec/controllers/instance_statistics/dev_ops_score_controller_spec.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe InstanceStatistics::DevOpsScoreController do
- it_behaves_like 'instance statistics availability'
-
- describe 'GET #index' do
- let(:user) { create(:user) }
-
- before do
- sign_in(user)
- end
-
- it_behaves_like 'tracking unique visits', :index do
- let(:request_params) { {} }
- let(:target_id) { 'i_analytics_dev_ops_score' }
- end
- end
-end
diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb
index 2b222331b55..a083cfac981 100644
--- a/spec/controllers/invites_controller_spec.rb
+++ b/spec/controllers/invites_controller_spec.rb
@@ -2,36 +2,142 @@
require 'spec_helper'
-RSpec.describe InvitesController do
- let(:token) { '123456' }
+RSpec.describe InvitesController, :snowplow do
let_it_be(:user) { create(:user) }
- let(:member) { create(:project_member, :invited, invite_token: token, invite_email: user.email) }
+ let(:member) { create(:project_member, :invited, invite_email: user.email) }
+ let(:raw_invite_token) { member.raw_invite_token }
let(:project_members) { member.source.users }
+ let(:md5_member_global_id) { Digest::MD5.hexdigest(member.to_global_id.to_s) }
+ let(:params) { { id: raw_invite_token } }
+ let(:snowplow_event) do
+ {
+ category: 'Growth::Acquisition::Experiment::InviteEmail',
+ label: md5_member_global_id,
+ property: group_type
+ }
+ end
before do
controller.instance_variable_set(:@member, member)
- sign_in(user)
end
describe 'GET #show' do
- it 'accepts user if invite email matches signed in user' do
- expect do
- get :show, params: { id: token }
- end.to change { project_members.include?(user) }.from(false).to(true)
+ subject(:request) { get :show, params: params }
+
+ context 'when logged in' do
+ before do
+ sign_in(user)
+ end
+
+ it 'accepts user if invite email matches signed in user' do
+ expect do
+ request
+ end.to change { project_members.include?(user) }.from(false).to(true)
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(flash[:notice]).to include 'You have been granted'
+ end
+
+ it 'forces re-confirmation if email does not match signed in user' do
+ member.invite_email = 'bogus@email.com'
+
+ expect do
+ request
+ end.not_to change { project_members.include?(user) }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(flash[:notice]).to be_nil
+ end
+
+ context 'when new_user_invite is not set' do
+ it 'does not track the user as experiment group' do
+ request
+
+ expect_no_snowplow_event
+ end
+ end
+
+ context 'when new_user_invite is experiment' do
+ let(:params) { { id: raw_invite_token, new_user_invite: 'experiment' } }
+ let(:group_type) { 'experiment_group' }
+
+ it 'tracks the user as experiment group' do
+ request
+
+ expect_snowplow_event(snowplow_event.merge(action: 'opened'))
+ expect_snowplow_event(snowplow_event.merge(action: 'accepted'))
+ end
+ end
+
+ context 'when new_user_invite is control' do
+ let(:params) { { id: raw_invite_token, new_user_invite: 'control' } }
+ let(:group_type) { 'control_group' }
+
+ it 'tracks the user as control group' do
+ request
+
+ expect_snowplow_event(snowplow_event.merge(action: 'opened'))
+ expect_snowplow_event(snowplow_event.merge(action: 'accepted'))
+ end
+ end
+ end
+
+ context 'when not logged in' do
+ context 'when inviter is a member' do
+ it 'is redirected to a new session with invite email param' do
+ request
+
+ expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
+ end
+ end
+
+ context 'when inviter is not a member' do
+ let(:params) { { id: '_bogus_token_' } }
+
+ it 'is redirected to a new session' do
+ request
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+ end
+ end
+
+ describe 'POST #accept' do
+ before do
+ sign_in(user)
+ end
+
+ subject(:request) { post :accept, params: params }
+
+ context 'when new_user_invite is not set' do
+ it 'does not track an event' do
+ request
+
+ expect_no_snowplow_event
+ end
+ end
+
+ context 'when new_user_invite is experiment' do
+ let(:params) { { id: raw_invite_token, new_user_invite: 'experiment' } }
+ let(:group_type) { 'experiment_group' }
+
+ it 'tracks the user as experiment group' do
+ request
- expect(response).to have_gitlab_http_status(:found)
- expect(flash[:notice]).to include 'You have been granted'
+ expect_snowplow_event(snowplow_event.merge(action: 'accepted'))
+ end
end
- it 'forces re-confirmation if email does not match signed in user' do
- member.invite_email = 'bogus@email.com'
+ context 'when new_user_invite is control' do
+ let(:params) { { id: raw_invite_token, new_user_invite: 'control' } }
+ let(:group_type) { 'control_group' }
- expect do
- get :show, params: { id: token }
- end.not_to change { project_members.include?(user) }
+ it 'tracks the user as control group' do
+ request
- expect(response).to have_gitlab_http_status(:ok)
- expect(flash[:notice]).to be_nil
+ expect_snowplow_event(snowplow_event.merge(action: 'accepted'))
+ end
end
end
end
diff --git a/spec/controllers/jira_connect/app_descriptor_controller_spec.rb b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb
new file mode 100644
index 00000000000..55bafa938a7
--- /dev/null
+++ b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::AppDescriptorController do
+ describe '#show' do
+ it 'returns JSON app descriptor' do
+ get :show
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ 'baseUrl' => 'https://test.host/-/jira_connect',
+ 'lifecycle' => {
+ 'installed' => '/events/installed',
+ 'uninstalled' => '/events/uninstalled'
+ },
+ 'links' => {
+ 'documentation' => 'http://test.host/help/integration/jira_development_panel#gitlabcom-1'
+ }
+ )
+ end
+ end
+end
diff --git a/spec/controllers/jira_connect/events_controller_spec.rb b/spec/controllers/jira_connect/events_controller_spec.rb
new file mode 100644
index 00000000000..d1a2dd6e7af
--- /dev/null
+++ b/spec/controllers/jira_connect/events_controller_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::EventsController do
+ describe '#installed' do
+ subject do
+ post :installed, params: {
+ clientKey: '1234',
+ sharedSecret: 'secret',
+ baseUrl: 'https://test.atlassian.net'
+ }
+ end
+
+ it 'saves the jira installation data' do
+ expect { subject }.to change { JiraConnectInstallation.count }.by(1)
+ end
+
+ it 'saves the correct values' do
+ subject
+
+ installation = JiraConnectInstallation.find_by_client_key('1234')
+
+ expect(installation.shared_secret).to eq('secret')
+ expect(installation.base_url).to eq('https://test.atlassian.net')
+ end
+
+ context 'client key already exists' do
+ it 'returns 422' do
+ create(:jira_connect_installation, client_key: '1234')
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ end
+ end
+
+ describe '#uninstalled' do
+ let!(:installation) { create(:jira_connect_installation) }
+ let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/events/uninstalled', 'POST', 'https://gitlab.test') }
+
+ before do
+ request.headers['Authorization'] = "JWT #{auth_token}"
+ end
+
+ subject { post :uninstalled }
+
+ context 'when JWT is invalid' do
+ let(:auth_token) { 'invalid_token' }
+
+ it 'returns 403' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'does not delete the installation' do
+ expect { subject }.not_to change { JiraConnectInstallation.count }
+ end
+ end
+
+ context 'when JWT is valid' do
+ let(:auth_token) do
+ Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret)
+ end
+
+ it 'deletes the installation' do
+ expect { subject }.to change { JiraConnectInstallation.count }.by(-1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/jira_connect/subscriptions_controller_spec.rb b/spec/controllers/jira_connect/subscriptions_controller_spec.rb
new file mode 100644
index 00000000000..95b359a989a
--- /dev/null
+++ b/spec/controllers/jira_connect/subscriptions_controller_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SubscriptionsController do
+ let_it_be(:installation) { create(:jira_connect_installation) }
+
+ describe '#index' do
+ before do
+ get :index, params: { jwt: jwt }
+ end
+
+ context 'without JWT' do
+ let(:jwt) { nil }
+
+ it 'returns 403' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with valid JWT' do
+ let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test') }
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
+
+ it 'returns 200' do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'removes X-Frame-Options to allow rendering in iframe' do
+ expect(response.headers['X-Frame-Options']).to be_nil
+ end
+ end
+ end
+
+ describe '#create' do
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+ let(:current_user) { user }
+
+ before do
+ group.add_maintainer(user)
+ end
+
+ subject { post :create, params: { jwt: jwt, namespace_path: group.path, format: :json } }
+
+ context 'without JWT' do
+ let(:jwt) { nil }
+
+ it 'returns 403' do
+ sign_in(user)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with valid JWT' do
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key }, installation.shared_secret) }
+
+ context 'signed in to GitLab' do
+ before do
+ sign_in(user)
+ end
+
+ context 'dev panel integration is available' do
+ it 'creates a subscription' do
+ expect { subject }.to change { installation.subscriptions.count }.from(0).to(1)
+ end
+
+ it 'returns 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'not signed in to GitLab' do
+ it 'returns 401' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+ end
+
+ describe '#destroy' do
+ let(:subscription) { create(:jira_connect_subscription, installation: installation) }
+
+ before do
+ delete :destroy, params: { jwt: jwt, id: subscription.id }
+ end
+
+ context 'without JWT' do
+ let(:jwt) { nil }
+
+ it 'returns 403' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with valid JWT' do
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key }, installation.shared_secret) }
+
+ it 'deletes the subscription' do
+ expect { subscription.reload }.to raise_error ActiveRecord::RecordNotFound
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb b/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb
index 2de824bbf3c..ecff173b8ac 100644
--- a/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb
@@ -11,6 +11,11 @@ RSpec.describe Ldap::OmniauthCallbacksController do
expect(request.env['warden']).to be_authenticated
end
+ it 'creates an authentication event record' do
+ expect { post provider }.to change { AuthenticationEvent.count }.by(1)
+ expect(AuthenticationEvent.last.provider).to eq(provider.to_s)
+ end
+
context 'with sign in prevented' do
let(:ldap_settings) { ldap_setting_defaults.merge(prevent_ldap_sign_in: true) }
diff --git a/spec/controllers/oauth/jira/authorizations_controller_spec.rb b/spec/controllers/oauth/jira/authorizations_controller_spec.rb
new file mode 100644
index 00000000000..0b4a691d7ec
--- /dev/null
+++ b/spec/controllers/oauth/jira/authorizations_controller_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Oauth::Jira::AuthorizationsController do
+ describe 'GET new' do
+ it 'redirects to OAuth authorization with correct params' do
+ get :new, params: { client_id: 'client-123', redirect_uri: 'http://example.com/' }
+
+ expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123',
+ response_type: 'code',
+ redirect_uri: oauth_jira_callback_url))
+ end
+ end
+
+ describe 'GET callback' do
+ it 'redirects to redirect_uri on session with code param' do
+ session['redirect_uri'] = 'http://example.com'
+
+ get :callback, params: { code: 'hash-123' }
+
+ expect(response).to redirect_to('http://example.com?code=hash-123')
+ end
+
+ it 'redirects to redirect_uri on session with code param preserving existing query' do
+ session['redirect_uri'] = 'http://example.com?foo=bar'
+
+ get :callback, params: { code: 'hash-123' }
+
+ expect(response).to redirect_to('http://example.com?foo=bar&code=hash-123')
+ end
+ end
+
+ describe 'POST access_token' do
+ it 'returns oauth params in a format Jira expects' do
+ expect_any_instance_of(Doorkeeper::Request::AuthorizationCode).to receive(:authorize) do
+ double(status: :ok, body: { 'access_token' => 'fake-123', 'scope' => 'foo', 'token_type' => 'bar' })
+ end
+
+ post :access_token, params: { code: 'code-123', client_id: 'client-123', client_secret: 'secret-123' }
+
+ expect(response.body).to eq('access_token=fake-123&scope=foo&token_type=bar')
+ end
+ end
+end
diff --git a/spec/controllers/oauth/token_info_controller_spec.rb b/spec/controllers/oauth/token_info_controller_spec.rb
index 91a986db251..6d01a534673 100644
--- a/spec/controllers/oauth/token_info_controller_spec.rb
+++ b/spec/controllers/oauth/token_info_controller_spec.rb
@@ -5,10 +5,10 @@ require 'spec_helper'
RSpec.describe Oauth::TokenInfoController do
describe '#show' do
context 'when the user is not authenticated' do
- it 'responds with a 401' do
+ it 'responds with a 400' do
get :show
- expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(response).to have_gitlab_http_status(:bad_request)
expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request')
end
end
@@ -36,10 +36,10 @@ RSpec.describe Oauth::TokenInfoController do
end
context 'when the doorkeeper_token is not recognised' do
- it 'responds with a 401' do
+ it 'responds with a 400' do
get :show, params: { access_token: 'unknown_token' }
- expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(response).to have_gitlab_http_status(:bad_request)
expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request')
end
end
@@ -49,10 +49,10 @@ RSpec.describe Oauth::TokenInfoController do
create(:oauth_access_token, created_at: 2.days.ago, expires_in: 10.minutes)
end
- it 'responds with a 401' do
+ it 'responds with a 400' do
get :show, params: { access_token: access_token.token }
- expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(response).to have_gitlab_http_status(:bad_request)
expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request')
end
end
@@ -60,10 +60,10 @@ RSpec.describe Oauth::TokenInfoController do
context 'when the token is revoked' do
let(:access_token) { create(:oauth_access_token, revoked_at: 2.days.ago) }
- it 'responds with a 401' do
+ it 'responds with a 400' do
get :show, params: { access_token: access_token.token }
- expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(response).to have_gitlab_http_status(:bad_request)
expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request')
end
end
diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb
index 3f7f0c55f38..291d51348e6 100644
--- a/spec/controllers/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/omniauth_callbacks_controller_spec.rb
@@ -170,6 +170,11 @@ RSpec.describe OmniauthCallbacksController, type: :controller do
expect(request.env['warden']).to be_authenticated
end
+ it 'creates an authentication event record' do
+ expect { post provider }.to change { AuthenticationEvent.count }.by(1)
+ expect(AuthenticationEvent.last.provider).to eq(provider.to_s)
+ end
+
context 'when user has no linked provider' do
let(:user) { create(:user) }
@@ -276,6 +281,51 @@ RSpec.describe OmniauthCallbacksController, type: :controller do
end
end
+ context 'atlassian_oauth2' do
+ let(:provider) { :atlassian_oauth2 }
+ let(:extern_uid) { 'my-uid' }
+
+ context 'when the user and identity already exist' do
+ let(:user) { create(:atlassian_user, extern_uid: extern_uid) }
+
+ it 'allows sign-in' do
+ post :atlassian_oauth2
+
+ expect(request.env['warden']).to be_authenticated
+ end
+ end
+
+ context 'for a new user' do
+ before do
+ stub_omniauth_setting(enabled: true, auto_link_user: true, allow_single_sign_on: ['atlassian_oauth2'])
+
+ user.destroy
+ end
+
+ it 'denies sign-in if sign-up is enabled, but block_auto_created_users is set' do
+ post :atlassian_oauth2
+
+ expect(flash[:alert]).to start_with 'Your account has been blocked.'
+ end
+
+ it 'accepts sign-in if sign-up is enabled' do
+ stub_omniauth_setting(block_auto_created_users: false)
+
+ post :atlassian_oauth2
+
+ expect(request.env['warden']).to be_authenticated
+ end
+
+ it 'denies sign-in if sign-up is not enabled' do
+ stub_omniauth_setting(allow_single_sign_on: false, block_auto_created_users: false)
+
+ post :atlassian_oauth2
+
+ expect(flash[:alert]).to start_with 'Signing in using your Atlassian account without a pre-existing GitLab account is not allowed.'
+ end
+ end
+ end
+
context 'salesforce' do
let(:extern_uid) { 'my-uid' }
let(:provider) { :salesforce }
diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb
index ba2c0c0455d..e9883107456 100644
--- a/spec/controllers/passwords_controller_spec.rb
+++ b/spec/controllers/passwords_controller_spec.rb
@@ -3,11 +3,13 @@
require 'spec_helper'
RSpec.describe PasswordsController do
- describe '#check_password_authentication_available' do
- before do
- @request.env["devise.mapping"] = Devise.mappings[:user]
- end
+ include DeviseHelpers
+ before do
+ set_devise_mapping(context: @request)
+ end
+
+ describe '#check_password_authentication_available' do
context 'when password authentication is disabled for the web interface and Git' do
it 'prevents a password reset' do
stub_application_setting(password_authentication_enabled_for_web: false)
@@ -30,4 +32,51 @@ RSpec.describe PasswordsController do
end
end
end
+
+ describe '#update' do
+ render_views
+
+ context 'updating the password' do
+ subject do
+ put :update, params: {
+ user: {
+ password: password,
+ password_confirmation: password_confirmation,
+ reset_password_token: reset_password_token
+ }
+ }
+ end
+
+ let(:password) { User.random_password }
+ let(:password_confirmation) { password }
+ let(:reset_password_token) { user.send_reset_password_instructions }
+ let(:user) { create(:user, password_automatically_set: true, password_expires_at: 10.minutes.ago) }
+
+ context 'password update is successful' do
+ it 'updates the password-related flags' do
+ subject
+ user.reload
+
+ expect(response).to redirect_to(new_user_session_path)
+ expect(flash[:notice]).to include('password has been changed successfully')
+ expect(user.password_automatically_set).to eq(false)
+ expect(user.password_expires_at).to be_nil
+ end
+ end
+
+ context 'password update is unsuccessful' do
+ let(:password_confirmation) { 'not_the_same_as_password' }
+
+ it 'does not update the password-related flags' do
+ subject
+ user.reload
+
+ expect(response).to render_template(:edit)
+ expect(response.body).to have_content("Password confirmation doesn't match Password")
+ expect(user.password_automatically_set).to eq(true)
+ expect(user.password_expires_at).not_to be_nil
+ end
+ end
+ end
+ end
end
diff --git a/spec/controllers/profiles/accounts_controller_spec.rb b/spec/controllers/profiles/accounts_controller_spec.rb
index 52a7a1609a1..c6e7866a659 100644
--- a/spec/controllers/profiles/accounts_controller_spec.rb
+++ b/spec/controllers/profiles/accounts_controller_spec.rb
@@ -45,5 +45,18 @@ RSpec.describe Profiles::AccountsController do
end
end
end
+
+ describe 'atlassian_oauth2 provider' do
+ let(:user) { create(:atlassian_user) }
+
+ it 'allows a user to unlink a connected account' do
+ expect(user.atlassian_identity).not_to be_nil
+
+ delete :unlink, params: { provider: 'atlassian_oauth2' }
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(user.reload.atlassian_identity).to be_nil
+ end
+ end
end
end
diff --git a/spec/controllers/profiles/emails_controller_spec.rb b/spec/controllers/profiles/emails_controller_spec.rb
index 246f8a6cd76..08552cc28fa 100644
--- a/spec/controllers/profiles/emails_controller_spec.rb
+++ b/spec/controllers/profiles/emails_controller_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Profiles::EmailsController do
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
before do
sign_in(user)
@@ -16,36 +16,43 @@ RSpec.describe Profiles::EmailsController do
end
describe '#create' do
- context 'when email address is valid' do
- let(:email_params) { { email: "add_email@example.com" } }
+ let(:email) { 'add_email@example.com' }
+ let(:params) { { email: { email: email } } }
- it 'sends an email confirmation' do
- expect { post(:create, params: { email: email_params }) }.to change { ActionMailer::Base.deliveries.size }
- end
+ subject { post(:create, params: params) }
+
+ it 'sends an email confirmation' do
+ expect { subject }.to change { ActionMailer::Base.deliveries.size }
end
context 'when email address is invalid' do
- let(:email_params) { { email: "test.@example.com" } }
+ let(:email) { 'invalid.@example.com' }
it 'does not send an email confirmation' do
- expect { post(:create, params: { email: email_params }) }.not_to change { ActionMailer::Base.deliveries.size }
+ expect { subject }.not_to change { ActionMailer::Base.deliveries.size }
end
end
end
describe '#resend_confirmation_instructions' do
- let(:email_params) { { email: "add_email@example.com" } }
+ let_it_be(:email) { create(:email, user: user) }
+ let(:params) { { id: email.id } }
+
+ subject { put(:resend_confirmation_instructions, params: params) }
it 'resends an email confirmation' do
- email = user.emails.create(email: 'add_email@example.com')
+ expect { subject }.to change { ActionMailer::Base.deliveries.size }
- expect { put(:resend_confirmation_instructions, params: { id: email }) }.to change { ActionMailer::Base.deliveries.size }
- expect(ActionMailer::Base.deliveries.last.to).to eq [email_params[:email]]
- expect(ActionMailer::Base.deliveries.last.subject).to match "Confirmation instructions"
+ expect(ActionMailer::Base.deliveries.last.to).to eq [email.email]
+ expect(ActionMailer::Base.deliveries.last.subject).to match 'Confirmation instructions'
end
- it 'unable to resend an email confirmation' do
- expect { put(:resend_confirmation_instructions, params: { id: 1 }) }.not_to change { ActionMailer::Base.deliveries.size }
+ context 'email does not exist' do
+ let(:params) { { id: non_existing_record_id } }
+
+ it 'does not send an email confirmation' do
+ expect { subject }.not_to change { ActionMailer::Base.deliveries.size }
+ end
end
end
end
diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb
index 66f6135df1e..17964ef4a43 100644
--- a/spec/controllers/profiles/keys_controller_spec.rb
+++ b/spec/controllers/profiles/keys_controller_spec.rb
@@ -20,4 +20,108 @@ RSpec.describe Profiles::KeysController do
expect(Key.last.expires_at).to be_like_time(expires_at)
end
end
+
+ describe "#get_keys" do
+ describe "non existent user" do
+ it "does not generally work" do
+ get :get_keys, params: { username: 'not-existent' }
+
+ expect(response).not_to be_successful
+ end
+ end
+
+ describe "user with no keys" do
+ it "does generally work" do
+ get :get_keys, params: { username: user.username }
+
+ expect(response).to be_successful
+ end
+
+ it "renders all keys separated with a new line" do
+ get :get_keys, params: { username: user.username }
+
+ expect(response.body).to eq("")
+ end
+ it "responds with text/plain content type" do
+ get :get_keys, params: { username: user.username }
+ expect(response.content_type).to eq("text/plain")
+ end
+ end
+
+ describe "user with keys" do
+ let!(:key) { create(:key, user: user) }
+ let!(:another_key) { create(:another_key, user: user) }
+ let!(:deploy_key) { create(:deploy_key, user: user) }
+
+ describe "while signed in" do
+ before do
+ sign_in(user)
+ end
+
+ it "does generally work" do
+ get :get_keys, params: { username: user.username }
+
+ expect(response).to be_successful
+ end
+
+ it "renders all non deploy keys separated with a new line" do
+ get :get_keys, params: { username: user.username }
+
+ expect(response.body).not_to eq('')
+ expect(response.body).to eq(user.all_ssh_keys.join("\n"))
+
+ expect(response.body).to include(key.key.sub(' dummy@gitlab.com', ''))
+ expect(response.body).to include(another_key.key.sub(' dummy@gitlab.com', ''))
+
+ expect(response.body).not_to include(deploy_key.key)
+ end
+
+ it "does not render the comment of the key" do
+ get :get_keys, params: { username: user.username }
+ expect(response.body).not_to match(/dummy@gitlab.com/)
+ end
+
+ it "responds with text/plain content type" do
+ get :get_keys, params: { username: user.username }
+
+ expect(response.content_type).to eq("text/plain")
+ end
+ end
+
+ describe 'when logged out' do
+ before do
+ sign_out(user)
+ end
+
+ it "still does generally work" do
+ get :get_keys, params: { username: user.username }
+
+ expect(response).to be_successful
+ end
+
+ it "renders all non deploy keys separated with a new line" do
+ get :get_keys, params: { username: user.username }
+
+ expect(response.body).not_to eq('')
+ expect(response.body).to eq(user.all_ssh_keys.join("\n"))
+
+ expect(response.body).to include(key.key.sub(' dummy@gitlab.com', ''))
+ expect(response.body).to include(another_key.key.sub(' dummy@gitlab.com', ''))
+
+ expect(response.body).not_to include(deploy_key.key)
+ end
+
+ it "does not render the comment of the key" do
+ get :get_keys, params: { username: user.username }
+ expect(response.body).not_to match(/dummy@gitlab.com/)
+ end
+
+ it "responds with text/plain content type" do
+ get :get_keys, params: { username: user.username }
+
+ expect(response.content_type).to eq("text/plain")
+ end
+ end
+ end
+ end
end
diff --git a/spec/controllers/profiles/notifications_controller_spec.rb b/spec/controllers/profiles/notifications_controller_spec.rb
index 40b4c8f0371..90df7cc0991 100644
--- a/spec/controllers/profiles/notifications_controller_spec.rb
+++ b/spec/controllers/profiles/notifications_controller_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe Profiles::NotificationsController do
expect(assigns(:group_notifications).map(&:source_id)).to include(subgroup.id)
end
- it 'has an N+1 (but should not)' do
+ it 'does not have an N+1' do
sign_in(user)
control = ActiveRecord::QueryRecorder.new do
@@ -46,10 +46,42 @@ RSpec.describe Profiles::NotificationsController do
create_list(:group, 2, parent: group)
- # We currently have an N + 1, switch to `not_to` once fixed
expect do
get :show
- end.to exceed_query_limit(control)
+ end.not_to exceed_query_limit(control)
+ end
+ end
+
+ context 'with group notifications' do
+ let(:notifications_per_page) { 5 }
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroups) { create_list(:group, 10, parent: group) }
+
+ before do
+ group.add_developer(user)
+ sign_in(user)
+ allow(Kaminari.config).to receive(:default_per_page).and_return(notifications_per_page)
+ end
+
+ it 'paginates the groups' do
+ get :show
+
+ expect(assigns(:group_notifications).count).to eq(5)
+ end
+
+ context 'when the user is not a member' do
+ let(:notifications_per_page) { 20 }
+
+ let_it_be(:public_group) { create(:group, :public) }
+
+ it 'does not show public groups', :aggregate_failures do
+ get :show
+
+ # Let's make sure we're grabbing all groups in one page, just in case
+ expect(assigns(:user_groups).count).to eq(11)
+ expect(assigns(:user_groups)).not_to include(public_group)
+ 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 1fb0b18622b..59eb33f4bc6 100644
--- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb
+++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
@@ -120,18 +120,46 @@ RSpec.describe Profiles::TwoFactorAuthsController do
end
describe 'DELETE destroy' do
- let(:user) { create(:user, :two_factor) }
+ subject { delete :destroy }
+
+ context 'for a user that has 2FA enabled' do
+ let(:user) { create(:user, :two_factor) }
+
+ it 'disables two factor' do
+ subject
+
+ expect(user.reload.two_factor_enabled?).to eq(false)
+ end
+
+ it 'redirects to profile_account_path' do
+ subject
+
+ expect(response).to redirect_to(profile_account_path)
+ end
- it 'disables two factor' do
- expect(user).to receive(:disable_two_factor!)
+ it 'displays a notice on success' do
+ subject
- delete :destroy
+ expect(flash[:notice])
+ .to eq _('Two-factor authentication has been disabled successfully!')
+ end
end
- it 'redirects to profile_account_path' do
- delete :destroy
+ context 'for a user that does not have 2FA enabled' do
+ let(:user) { create(:user) }
- expect(response).to redirect_to(profile_account_path)
+ it 'redirects to profile_account_path' do
+ subject
+
+ expect(response).to redirect_to(profile_account_path)
+ end
+
+ it 'displays an alert on failure' do
+ subject
+
+ expect(flash[:alert])
+ .to eq _('Two-factor authentication is not enabled for this user')
+ end
end
end
end
diff --git a/spec/controllers/profiles/webauthn_registrations_controller_spec.rb b/spec/controllers/profiles/webauthn_registrations_controller_spec.rb
new file mode 100644
index 00000000000..0c475039963
--- /dev/null
+++ b/spec/controllers/profiles/webauthn_registrations_controller_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Profiles::WebauthnRegistrationsController do
+ let(:user) { create(:user, :two_factor_via_webauthn) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe '#destroy' do
+ it 'deletes the given webauthn registration' do
+ registration_to_delete = user.webauthn_registrations.first
+
+ expect { delete :destroy, params: { id: registration_to_delete.id } }.to change { user.webauthn_registrations.count }.by(-1)
+ expect(response).to be_redirect
+ end
+ end
+end
diff --git a/spec/controllers/projects/badges_controller_spec.rb b/spec/controllers/projects/badges_controller_spec.rb
index 7e7a630921f..242b2fd3ec6 100644
--- a/spec/controllers/projects/badges_controller_spec.rb
+++ b/spec/controllers/projects/badges_controller_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe Projects::BadgesController do
- let(:project) { pipeline.project }
- let!(:pipeline) { create(:ci_empty_pipeline) }
- let(:user) { create(:user) }
+ let_it_be(:project, reload: true) { create(:project, :repository) }
+ let_it_be(:pipeline, reload: true) { create(:ci_empty_pipeline, project: project) }
+ let_it_be(:user) { create(:user) }
shared_examples 'a badge resource' do |badge_type|
context 'when pipelines are public' do
@@ -137,6 +137,26 @@ RSpec.describe Projects::BadgesController do
describe '#pipeline' do
it_behaves_like 'a badge resource', :pipeline
+
+ context 'with ignore_skipped param' do
+ render_views
+
+ before do
+ pipeline.update!(status: :skipped)
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ it 'returns skipped badge if set to false' do
+ get_badge(:pipeline, ignore_skipped: false)
+ expect(response.body).to include('skipped')
+ end
+
+ it 'does not return skipped badge if set to true' do
+ get_badge(:pipeline, ignore_skipped: true)
+ expect(response.body).to include('unknown')
+ end
+ end
end
describe '#coverage' do
@@ -148,7 +168,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))
+ }.merge(args.slice(:style, :key_text, :key_width, :ignore_skipped))
get badge, params: params, format: :svg
end
diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb
index 9fee97f938c..b998dee09b2 100644
--- a/spec/controllers/projects/blob_controller_spec.rb
+++ b/spec/controllers/projects/blob_controller_spec.rb
@@ -347,6 +347,13 @@ RSpec.describe Projects::BlobController do
end
end
end
+
+ it_behaves_like 'tracking unique hll events', :track_editor_edit_actions do
+ subject { put :update, params: default_params, format: format }
+
+ let(:target_id) { 'g_edit_by_sfe' }
+ let(:expected_type) { instance_of(Integer) }
+ end
end
describe 'DELETE destroy' do
@@ -436,4 +443,32 @@ RSpec.describe Projects::BlobController do
end
end
end
+
+ describe 'POST create' do
+ let(:user) { create(:user) }
+ let(:default_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: 'master',
+ branch_name: 'master',
+ file_name: 'docs/EXAMPLE_FILE',
+ content: 'Added changes',
+ commit_message: 'Create CHANGELOG'
+ }
+ end
+
+ before do
+ project.add_developer(user)
+
+ sign_in(user)
+ end
+
+ it_behaves_like 'tracking unique hll events', :track_editor_edit_actions do
+ subject { post :create, params: default_params, format: format }
+
+ let(:target_id) { 'g_edit_by_sfe' }
+ let(:expected_type) { instance_of(Integer) }
+ end
+ end
end
diff --git a/spec/controllers/projects/ci/lints_controller_spec.rb b/spec/controllers/projects/ci/lints_controller_spec.rb
index b3e08292546..22f052e39b7 100644
--- a/spec/controllers/projects/ci/lints_controller_spec.rb
+++ b/spec/controllers/projects/ci/lints_controller_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe Projects::Ci::LintsController do
include StubRequests
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
before do
sign_in(user)
@@ -20,7 +20,7 @@ RSpec.describe Projects::Ci::LintsController do
get :show, params: { namespace_id: project.namespace, project_id: project }
end
- it { expect(response).to be_successful }
+ it { expect(response).to have_gitlab_http_status(:ok) }
it 'renders show page' do
expect(response).to render_template :show
@@ -47,7 +47,8 @@ RSpec.describe Projects::Ci::LintsController do
describe 'POST #create' do
subject { post :create, params: params }
- let(:params) { { namespace_id: project.namespace, project_id: project, content: content } }
+ let(:format) { :html }
+ let(:params) { { namespace_id: project.namespace, project_id: project, content: content, format: format } }
let(:remote_file_path) { 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' }
let(:remote_file_content) do
@@ -71,6 +72,20 @@ RSpec.describe Projects::Ci::LintsController do
HEREDOC
end
+ shared_examples 'successful request with format json' do
+ context 'with format json' do
+ let(:format) { :json }
+ let(:parsed_body) { Gitlab::Json.parse(response.body) }
+
+ it 'renders json' do
+ expect(response).to have_gitlab_http_status :ok
+ expect(response.content_type).to eq 'application/json'
+ expect(parsed_body).to include('errors', 'warnings', 'jobs', 'valid')
+ expect(parsed_body).to match_schema('entities/lint_result_entity')
+ end
+ end
+ end
+
context 'with a valid gitlab-ci.yml' do
before do
stub_full_request(remote_file_path).to_return(body: remote_file_content)
@@ -78,27 +93,30 @@ RSpec.describe Projects::Ci::LintsController do
end
shared_examples 'returns a successful validation' do
- it 'returns successfully' do
+ before do
subject
- expect(response).to be_successful
end
- it 'render show page' do
- subject
+ it 'returns successfully' do
+ expect(response).to have_gitlab_http_status :ok
+ end
+
+ it 'renders show page' do
expect(response).to render_template :show
end
it 'retrieves project' do
- subject
expect(assigns(:project)).to eq(project)
end
+
+ it_behaves_like 'successful request with format json'
end
context 'using legacy validation (YamlProcessor)' do
it_behaves_like 'returns a successful validation'
it 'runs validations through YamlProcessor' do
- expect(Gitlab::Ci::YamlProcessor).to receive(:new_with_validation_errors).and_call_original
+ expect(Gitlab::Ci::YamlProcessor).to receive(:new).and_call_original
subject
end
@@ -126,7 +144,7 @@ RSpec.describe Projects::Ci::LintsController do
it_behaves_like 'returns a successful validation'
it 'runs validations through YamlProcessor' do
- expect(Gitlab::Ci::YamlProcessor).to receive(:new_with_validation_errors).and_call_original
+ expect(Gitlab::Ci::YamlProcessor).to receive(:new).and_call_original
subject
end
@@ -145,22 +163,30 @@ RSpec.describe Projects::Ci::LintsController do
before do
project.add_developer(user)
+ subject
end
- it 'assigns errors' do
- subject
+ it 'assigns result with errors' do
+ expect(assigns[:result].errors).to match_array([
+ 'jobs rubocop config should implement a script: or a trigger: keyword',
+ 'jobs config should contain at least one visible job'
+ ])
+ end
- expect(assigns[:errors]).to eq(['root config contains unknown keys: rubocop'])
+ it 'render show page' do
+ expect(response).to render_template :show
end
+ it_behaves_like 'successful request with format json'
+
context 'with dry_run mode' do
subject { post :create, params: params.merge(dry_run: 'true') }
- it 'assigns errors' do
- subject
-
- expect(assigns[:errors]).to eq(['root config contains unknown keys: rubocop'])
+ it 'assigns result with errors' do
+ expect(assigns[:result].errors).to eq(['jobs rubocop config should implement a script: or a trigger: keyword'])
end
+
+ it_behaves_like 'successful request with format json'
end
end
@@ -174,6 +200,14 @@ RSpec.describe Projects::Ci::LintsController do
it 'responds with 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ context 'with format json' do
+ let(:format) { :json }
+
+ it 'responds with 404' do
+ expect(response).to have_gitlab_http_status :not_found
+ end
+ end
end
end
end
diff --git a/spec/controllers/projects/graphs_controller_spec.rb b/spec/controllers/projects/graphs_controller_spec.rb
index 49def8f80b0..be89fa0d361 100644
--- a/spec/controllers/projects/graphs_controller_spec.rb
+++ b/spec/controllers/projects/graphs_controller_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe Projects::GraphsController do
context 'when anonymous users can read build report results' do
it 'sets the daily coverage options' do
- Timecop.freeze do
+ freeze_time do
get(:charts, params: { namespace_id: project.namespace.path, project_id: project.path, id: 'master' })
expect(assigns[:daily_coverage_options]).to eq(
diff --git a/spec/controllers/projects/issue_links_controller_spec.rb b/spec/controllers/projects/issue_links_controller_spec.rb
new file mode 100644
index 00000000000..bce109b7c79
--- /dev/null
+++ b/spec/controllers/projects/issue_links_controller_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::IssueLinksController do
+ let_it_be(:namespace) { create(:group, :public) }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:issue1) { create(:issue, project: project) }
+ let_it_be(:issue2) { create(:issue, project: project) }
+
+ describe 'GET #index' do
+ let_it_be(:issue_link) { create(:issue_link, source: issue1, target: issue2, link_type: 'relates_to') }
+
+ def get_link(user, issue)
+ sign_in(user)
+
+ params = {
+ namespace_id: issue.project.namespace.to_param,
+ project_id: issue.project,
+ issue_id: issue.iid
+ }
+
+ get :index, params: params, as: :json
+ end
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'returns success response' do
+ get_link(user, issue1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ link = json_response.first
+ expect(link['id']).to eq(issue2.id)
+ expect(link['link_type']).to eq('relates_to')
+ end
+ end
+
+ describe 'POST #create' do
+ def create_link(user, issue, target)
+ sign_in(user)
+
+ post_params = {
+ namespace_id: issue.project.namespace.to_param,
+ project_id: issue.project,
+ issue_id: issue.iid,
+ issuable_references: [target.to_reference],
+ link_type: 'relates_to'
+ }
+
+ post :create, params: post_params, as: :json
+ end
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'returns success response' do
+ create_link(user, issue1, issue2)
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ link = json_response['issuables'].first
+ expect(link['id']).to eq(issue2.id)
+ expect(link['link_type']).to eq('relates_to')
+ end
+ end
+end
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index a0e478ef368..ed5198bf015 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -6,9 +6,9 @@ RSpec.describe Projects::IssuesController do
include ProjectForksHelper
include_context 'includes Spam constants'
- let(:project) { create(:project) }
- let(:user) { create(:user) }
- let(:issue) { create(:issue, project: project) }
+ let_it_be(:project, reload: true) { create(:project) }
+ let_it_be(:user, reload: true) { create(:user) }
+ let(:issue) { create(:issue, project: project) }
describe "GET #index" do
context 'external issue tracker' do
@@ -39,8 +39,8 @@ RSpec.describe Projects::IssuesController do
end
context 'when project has moved' do
- let(:new_project) { create(:project) }
- let(:issue) { create(:issue, project: new_project) }
+ let_it_be(:new_project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: new_project) }
before do
project.route.destroy
@@ -82,6 +82,16 @@ RSpec.describe Projects::IssuesController do
expect(response).to have_gitlab_http_status(:ok)
end
+ it 'returns only list type issues' do
+ issue = create(:issue, project: project)
+ incident = create(:issue, project: project, issue_type: 'incident')
+ create(:issue, project: project, issue_type: 'test_case')
+
+ get :index, params: { namespace_id: project.namespace, project_id: project }
+
+ expect(assigns(:issues)).to contain_exactly(issue, incident)
+ end
+
it "returns 301 if request path doesn't match project path" do
get :index, params: { namespace_id: project.namespace, project_id: project.path.upcase }
@@ -297,6 +307,7 @@ RSpec.describe Projects::IssuesController do
project.add_developer(developer)
end
+ let_it_be(:issue) { create(:issue, project: project) }
let(:developer) { user }
let(:params) do
{
@@ -401,7 +412,7 @@ RSpec.describe Projects::IssuesController do
end
context 'when moving issue to another private project' do
- let(:another_project) { create(:project, :private) }
+ let_it_be(:another_project) { create(:project, :private) }
context 'when user has access to move issue' do
before do
@@ -438,10 +449,10 @@ RSpec.describe Projects::IssuesController do
end
describe 'PUT #reorder' do
- let(:group) { create(:group, projects: [project]) }
- let!(:issue1) { create(:issue, project: project, relative_position: 10) }
- let!(:issue2) { create(:issue, project: project, relative_position: 20) }
- let!(:issue3) { create(:issue, project: project, relative_position: 30) }
+ let_it_be(:group) { create(:group, projects: [project]) }
+ let_it_be(:issue1) { create(:issue, project: project, relative_position: 10) }
+ let_it_be(:issue2) { create(:issue, project: project, relative_position: 20) }
+ let_it_be(:issue3) { create(:issue, project: project, relative_position: 30) }
before do
sign_in(user)
@@ -657,15 +668,15 @@ RSpec.describe Projects::IssuesController do
end
describe 'Confidential Issues' do
- let(:project) { create(:project_empty_repo, :public) }
- let(:assignee) { create(:assignee) }
- let(:author) { create(:user) }
- let(:non_member) { create(:user) }
- let(:member) { create(:user) }
- let(:admin) { create(:admin) }
- let!(:issue) { create(:issue, project: project) }
- let!(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) }
- let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignees: [assignee]) }
+ let_it_be(:project) { create(:project_empty_repo, :public) }
+ let_it_be(:assignee) { create(:assignee) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:non_member) { create(:user) }
+ let_it_be(:member) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) }
+ let_it_be(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignees: [assignee]) }
describe 'GET #index' do
it 'does not list confidential issues for guests' do
@@ -1010,6 +1021,25 @@ RSpec.describe Projects::IssuesController do
end
end
end
+
+ it 'logs the view with Gitlab::Search::RecentIssues' do
+ sign_in(user)
+ recent_issues_double = instance_double(::Gitlab::Search::RecentIssues, log_view: nil)
+ expect(::Gitlab::Search::RecentIssues).to receive(:new).with(user: user).and_return(recent_issues_double)
+
+ go(id: issue.to_param)
+
+ expect(response).to be_successful
+ expect(recent_issues_double).to have_received(:log_view).with(issue)
+ end
+
+ context 'when not logged in' do
+ it 'does not log the view with Gitlab::Search::RecentIssues' do
+ expect(::Gitlab::Search::RecentIssues).not_to receive(:new)
+
+ go(id: issue.to_param)
+ end
+ end
end
describe 'GET #realtime_changes' do
@@ -1077,7 +1107,7 @@ RSpec.describe Projects::IssuesController do
end
context 'resolving discussions in MergeRequest' do
- let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
+ let_it_be(:discussion) { create(:diff_note_on_merge_request).to_discussion }
let(:merge_request) { discussion.noteable }
let(:project) { merge_request.source_project }
@@ -1376,9 +1406,9 @@ RSpec.describe Projects::IssuesController do
end
context "when the user is owner" do
- let(:owner) { create(:user) }
- let(:namespace) { create(:namespace, owner: owner) }
- let(:project) { create(:project, namespace: namespace) }
+ let_it_be(:owner) { create(:user) }
+ let_it_be(:namespace) { create(:namespace, owner: owner) }
+ let_it_be(:project) { create(:project, namespace: namespace) }
before do
sign_in(owner)
@@ -1461,7 +1491,8 @@ RSpec.describe Projects::IssuesController do
describe 'POST create_merge_request' do
let(:target_project_id) { nil }
- let(:project) { create(:project, :repository, :public) }
+
+ let_it_be(:project) { create(:project, :repository, :public) }
before do
project.add_developer(user)
@@ -1539,7 +1570,7 @@ RSpec.describe Projects::IssuesController do
end
describe 'POST #import_csv' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
let(:file) { fixture_file_upload('spec/fixtures/csv_comma.csv') }
context 'unauthorized' do
@@ -1621,7 +1652,7 @@ RSpec.describe Projects::IssuesController do
end
context 'when not logged in' do
- let(:project) { create(:project_empty_repo, :public) }
+ let(:empty_project) { create(:project_empty_repo, :public) }
it 'redirects to the sign in page' do
request_csv
@@ -1738,7 +1769,7 @@ RSpec.describe Projects::IssuesController do
end
context 'with cross-reference system note', :request_store do
- let(:new_issue) { create(:issue) }
+ let_it_be(:new_issue) { create(:issue) }
let(:cross_reference) { "mentioned in #{new_issue.to_reference(issue.project)}" }
before do
@@ -1836,7 +1867,7 @@ RSpec.describe Projects::IssuesController do
end
context 'private project with token authentication' do
- let(:private_project) { create(:project, :private) }
+ let_it_be(:private_project) { create(:project, :private) }
it_behaves_like 'authenticates sessionless user', :index, :atom, ignore_incrementing: true do
before do
@@ -1856,7 +1887,7 @@ RSpec.describe Projects::IssuesController do
end
context 'public project with token authentication' do
- let(:public_project) { create(:project, :public) }
+ let_it_be(:public_project) { create(:project, :public) }
it_behaves_like 'authenticates sessionless user', :index, :atom, public: true do
before do
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index 818b1c30b37..94cce1964ca 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -201,33 +201,61 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
end
context 'when job has artifacts' do
- before do
- get_show_json
- end
-
context 'with not expiry date' do
let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
it 'exposes needed information' do
+ get_show_json
+
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('job/job_details')
expect(json_response['artifact']['download_path']).to match(%r{artifacts/download})
expect(json_response['artifact']['browse_path']).to match(%r{artifacts/browse})
+ expect(json_response['artifact']).not_to have_key('keep_path')
expect(json_response['artifact']).not_to have_key('expired')
expect(json_response['artifact']).not_to have_key('expired_at')
end
end
- context 'with expiry date' do
+ context 'with expired artifacts' do
let(:job) { create(:ci_build, :success, :artifacts, :expired, pipeline: pipeline) }
- it 'exposes needed information' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('job/job_details')
- expect(json_response['artifact']).not_to have_key('download_path')
- expect(json_response['artifact']).not_to have_key('browse_path')
- expect(json_response['artifact']['expired']).to eq(true)
- expect(json_response['artifact']['expire_at']).not_to be_empty
+ context 'when artifacts are unlocked' do
+ before do
+ job.pipeline.unlocked!
+ end
+
+ it 'exposes needed information' do
+ get_show_json
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('job/job_details')
+ expect(json_response['artifact']).not_to have_key('download_path')
+ expect(json_response['artifact']).not_to have_key('browse_path')
+ expect(json_response['artifact']).not_to have_key('keep_path')
+ expect(json_response['artifact']['expired']).to eq(true)
+ expect(json_response['artifact']['expire_at']).not_to be_empty
+ expect(json_response['artifact']['locked']).to eq(false)
+ end
+ end
+
+ context 'when artifacts are locked' do
+ before do
+ job.pipeline.artifacts_locked!
+ end
+
+ it 'exposes needed information' do
+ get_show_json
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('job/job_details')
+ expect(json_response['artifact']).to have_key('download_path')
+ expect(json_response['artifact']).to have_key('browse_path')
+ expect(json_response['artifact']).to have_key('keep_path')
+ expect(json_response['artifact']['expired']).to eq(true)
+ expect(json_response['artifact']['expire_at']).not_to be_empty
+ expect(json_response['artifact']['locked']).to eq(true)
+ end
end
end
end
diff --git a/spec/controllers/projects/merge_requests/content_controller_spec.rb b/spec/controllers/projects/merge_requests/content_controller_spec.rb
index 7fb20b4666a..67d3ef6f4f0 100644
--- a/spec/controllers/projects/merge_requests/content_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/content_controller_spec.rb
@@ -48,6 +48,9 @@ RSpec.describe Projects::MergeRequests::ContentController do
expect(merge_request).to receive(:check_mergeability)
do_request(:widget)
+
+ expect(response).to match_response_schema('entities/merge_request_poll_widget')
+ expect(response.headers['Poll-Interval']).to eq('10000')
end
context 'merged merge request' do
@@ -59,6 +62,21 @@ RSpec.describe Projects::MergeRequests::ContentController do
do_request(:widget)
expect(response).to match_response_schema('entities/merge_request_poll_widget')
+ expect(response.headers['Poll-Interval']).to eq('300000')
+ end
+ end
+
+ context 'with coverage data' do
+ let(:merge_request) { create(:merge_request, target_project: project, source_project: project, head_pipeline: head_pipeline) }
+ let!(:base_pipeline) { create(:ci_empty_pipeline, project: project, ref: merge_request.target_branch, sha: merge_request.diff_base_sha) }
+ let!(:head_pipeline) { create(:ci_empty_pipeline, project: project) }
+ let!(:rspec_base) { create(:ci_build, name: 'rspec', coverage: 93.1, pipeline: base_pipeline) }
+ let!(:rspec_head) { create(:ci_build, name: 'rspec', coverage: 97.1, pipeline: head_pipeline) }
+
+ it 'renders widget MR entity as json' do
+ do_request(:widget)
+
+ expect(response).to match_response_schema('entities/merge_request_poll_widget')
end
end
end
diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
index 217447c2ad6..91770a00081 100644
--- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
@@ -58,39 +58,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do
end
end
- shared_examples 'persisted preferred diff view cookie' do
- context 'with view param' do
- before do
- go(view: 'parallel')
- end
-
- it 'saves the preferred diff view in a cookie' do
- expect(response.cookies['diff_view']).to eq('parallel')
- end
-
- it 'only renders the required view', :aggregate_failures do
- diff_files_without_deletions = json_response['diff_files'].reject { |f| f['deleted_file'] }
- have_no_inline_diff_lines = satisfy('have no inline diff lines') do |diff_file|
- !diff_file.has_key?('highlighted_diff_lines')
- end
-
- expect(diff_files_without_deletions).to all(have_key('parallel_diff_lines'))
- expect(diff_files_without_deletions).to all(have_no_inline_diff_lines)
- end
- end
-
- context 'when the user cannot view the merge request' do
- before do
- project.team.truncate
- go
- end
-
- it 'returns a 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
-
shared_examples "diff note on-demand position creation" do
it "updates diff discussion positions" do
service = double("service")
@@ -155,7 +122,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do
it_behaves_like 'forked project with submodules'
end
- it_behaves_like 'persisted preferred diff view cookie'
it_behaves_like 'cached diff collection'
it_behaves_like 'diff note on-demand position creation'
end
@@ -414,6 +380,7 @@ RSpec.describe Projects::MergeRequests::DiffsController do
def collection_arguments(pagination_data = {})
{
+ environment: nil,
merge_request: merge_request,
diff_view: :inline,
pagination_data: {
@@ -439,18 +406,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do
it_behaves_like '404 for unexistent diffable'
- context 'when feature is disabled' do
- before do
- stub_feature_flags(diffs_batch_load: false)
- end
-
- it 'returns 404' do
- go
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
context 'when not authorized' do
let(:other_user) { create(:user) }
@@ -522,7 +477,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do
end
it_behaves_like 'forked project with submodules'
- it_behaves_like 'persisted preferred diff view cookie'
it_behaves_like 'cached diff collection'
context 'diff unfolding' do
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 8e1b250cd3c..ee194e5ff2f 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -44,6 +44,16 @@ RSpec.describe Projects::MergeRequestsController do
get :show, params: params.merge(extra_params)
end
+ context 'with view param' do
+ before do
+ go(view: 'parallel')
+ end
+
+ it 'saves the preferred diff view in a cookie' do
+ expect(response.cookies['diff_view']).to eq('parallel')
+ end
+ end
+
context 'when merge request is unchecked' do
before do
merge_request.mark_as_unchecked!
@@ -77,6 +87,22 @@ RSpec.describe Projects::MergeRequestsController do
end
end
+ context 'with `default_merge_ref_for_diffs` feature flag enabled' do
+ before do
+ stub_feature_flags(default_merge_ref_for_diffs: true)
+ go
+ end
+
+ it 'adds the diff_head parameter' do
+ expect(assigns["endpoint_metadata_url"]).to eq(
+ diffs_metadata_project_json_merge_request_path(
+ project,
+ merge_request,
+ 'json',
+ diff_head: true))
+ end
+ end
+
context 'when diff is missing' do
render_views
@@ -97,6 +123,16 @@ RSpec.describe Projects::MergeRequestsController do
expect(response).to be_successful
end
+ it 'logs the view with Gitlab::Search::RecentMergeRequests' do
+ recent_merge_requests_double = instance_double(::Gitlab::Search::RecentMergeRequests, log_view: nil)
+ expect(::Gitlab::Search::RecentMergeRequests).to receive(:new).with(user: user).and_return(recent_merge_requests_double)
+
+ go(format: :html)
+
+ expect(response).to be_successful
+ expect(recent_merge_requests_double).to have_received(:log_view).with(merge_request)
+ end
+
context "that is invalid" do
let(:merge_request) { create(:invalid_merge_request, target_project: project, source_project: project) }
diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb
index 0c7391c1b9c..fa32d32f552 100644
--- a/spec/controllers/projects/milestones_controller_spec.rb
+++ b/spec/controllers/projects/milestones_controller_spec.rb
@@ -135,10 +135,6 @@ RSpec.describe Projects::MilestonesController do
end
describe "#destroy" do
- before do
- stub_feature_flags(track_resource_milestone_change_events: false)
- end
-
it "removes milestone" do
expect(issue.milestone_id).to eq(milestone.id)
@@ -153,10 +149,6 @@ RSpec.describe Projects::MilestonesController do
merge_request.reload
expect(merge_request.milestone_id).to eq(nil)
-
- # Check system note left for milestone removal
- last_note = project.issues.find(issue.id).notes[-1].note
- expect(last_note).to eq('removed milestone')
end
end
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index 570d65dba4f..8c59b2b378f 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -98,7 +98,7 @@ RSpec.describe Projects::NotesController do
let(:page_2_boundary) { microseconds(page_2.last.updated_at + NotesFinder::FETCH_OVERLAP) }
around do |example|
- Timecop.freeze do
+ freeze_time do
example.run
end
end
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index ef560f6426b..c3be7de25a8 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe Projects::PipelinesController do
end
end
- it 'executes N+1 queries' do
+ it 'does not execute N+1 queries' do
get_pipelines_index_json
control_count = ActiveRecord::QueryRecorder.new do
@@ -53,7 +53,7 @@ RSpec.describe Projects::PipelinesController do
create_all_pipeline_types
# There appears to be one extra query for Pipelines#has_warnings? for some reason
- expect { get_pipelines_index_json }.not_to exceed_query_limit(control_count + 7)
+ expect { get_pipelines_index_json }.not_to exceed_query_limit(control_count + 1)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['pipelines'].count).to eq 12
end
@@ -763,6 +763,71 @@ RSpec.describe Projects::PipelinesController do
end
end
+ describe 'POST create.json' do
+ let(:project) { create(:project, :public, :repository) }
+
+ subject do
+ post :create, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ pipeline: { ref: 'master' }
+ },
+ format: :json
+ end
+
+ before do
+ project.add_developer(user)
+ project.project_feature.update(builds_access_level: feature)
+ end
+
+ context 'with a valid .gitlab-ci.yml file' do
+ before do
+ stub_ci_pipeline_yaml_file(YAML.dump({
+ test: {
+ stage: 'test',
+ script: 'echo'
+ }
+ }))
+ end
+
+ it 'creates a pipeline' do
+ expect { subject }.to change { project.ci_pipelines.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['id']).to eq(project.ci_pipelines.last.id)
+ end
+ end
+
+ context 'with an invalid .gitlab-ci.yml file' do
+ before do
+ stub_ci_pipeline_yaml_file(YAML.dump({
+ build: {
+ stage: 'build',
+ script: 'echo',
+ rules: [{ when: 'always' }]
+ },
+ test: {
+ stage: 'invalid',
+ script: 'echo'
+ }
+ }))
+ end
+
+ it 'does not create a pipeline' do
+ expect { subject }.not_to change { project.ci_pipelines.count }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['errors']).to eq([
+ 'test job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post'
+ ])
+ expect(json_response['warnings'][0]).to include(
+ 'jobs:build may allow multiple pipelines to run for a single action due to `rules:when`'
+ )
+ expect(json_response['total_warnings']).to eq(1)
+ end
+ end
+ end
+
describe 'POST retry.json' do
let!(:pipeline) { create(:ci_pipeline, :failed, project: project) }
let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb
index 50f474c0222..8f928cf3382 100644
--- a/spec/controllers/projects/services_controller_spec.rb
+++ b/spec/controllers/projects/services_controller_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Projects::ServicesController do
+ include JiraServiceHelper
+
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:service) { create(:jira_service, project: project) }
@@ -54,8 +56,7 @@ RSpec.describe Projects::ServicesController do
end
it 'returns success' do
- stub_request(:get, 'http://example.com/rest/api/2/serverInfo')
- .to_return(status: 200, body: '{}')
+ stub_jira_service_test
expect(Gitlab::HTTP).to receive(:get).with('/rest/api/2/serverInfo', any_args).and_call_original
@@ -66,8 +67,7 @@ RSpec.describe Projects::ServicesController do
end
it 'returns success' do
- stub_request(:get, 'http://example.com/rest/api/2/serverInfo')
- .to_return(status: 200, body: '{}')
+ stub_jira_service_test
expect(Gitlab::HTTP).to receive(:get).with('/rest/api/2/serverInfo', any_args).and_call_original
@@ -123,7 +123,7 @@ RSpec.describe Projects::ServicesController do
expect(response).to be_successful
expect(json_response).to eq(
'error' => true,
- 'message' => 'Test failed.',
+ 'message' => 'Connection failed. Please check your settings.',
'service_response' => '',
'test_failed' => true
)
@@ -136,7 +136,7 @@ RSpec.describe Projects::ServicesController do
let(:service_params) { { active: true } }
let(:params) { project_params(service: service_params) }
- let(:message) { 'Jira activated.' }
+ let(:message) { 'Jira settings saved and active.' }
let(:redirect_url) { edit_project_service_path(project, service) }
before do
@@ -175,7 +175,7 @@ RSpec.describe Projects::ServicesController do
context 'when param `active` is set to false' do
let(:service_params) { { active: false } }
- let(:message) { 'Jira settings saved, but not activated.' }
+ let(:message) { 'Jira settings saved, but not active.' }
it_behaves_like 'service update'
end
@@ -200,6 +200,7 @@ RSpec.describe Projects::ServicesController do
describe 'as JSON' do
before do
+ stub_jira_service_test
put :update, params: project_params(service: service_params, format: :json)
end
diff --git a/spec/controllers/projects/settings/operations_controller_spec.rb b/spec/controllers/projects/settings/operations_controller_spec.rb
index 191b718af56..ca1b0d2fe15 100644
--- a/spec/controllers/projects/settings/operations_controller_spec.rb
+++ b/spec/controllers/projects/settings/operations_controller_spec.rb
@@ -152,7 +152,8 @@ RSpec.describe Projects::Settings::OperationsController do
create_issue: 'false',
send_email: 'false',
issue_template_key: 'some-other-template',
- pagerduty_active: 'true'
+ pagerduty_active: 'true',
+ auto_close_incident: 'true'
}
}
end
@@ -171,7 +172,7 @@ RSpec.describe Projects::Settings::OperationsController do
new_incident_management_settings = params
expect(Gitlab::Tracking).to receive(:event)
- .with('IncidentManagement::Settings', event_key, kind_of(Hash))
+ .with('IncidentManagement::Settings', event_key, any_args)
patch :update, params: project_params(project, incident_management_setting_attributes: new_incident_management_settings)
@@ -187,6 +188,8 @@ RSpec.describe Projects::Settings::OperationsController do
it_behaves_like 'a gitlab tracking event', { send_email: '0' }, 'disabled_sending_emails'
it_behaves_like 'a gitlab tracking event', { pagerduty_active: '1' }, 'enabled_pagerduty_webhook'
it_behaves_like 'a gitlab tracking event', { pagerduty_active: '0' }, 'disabled_pagerduty_webhook'
+ it_behaves_like 'a gitlab tracking event', { auto_close_incident: '1' }, 'enabled_auto_close_incident'
+ it_behaves_like 'a gitlab tracking event', { auto_close_incident: '0' }, 'disabled_auto_close_incident'
end
end
diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb
index bb9b556f442..d0e412dfdb8 100644
--- a/spec/controllers/projects/snippets_controller_spec.rb
+++ b/spec/controllers/projects/snippets_controller_spec.rb
@@ -6,12 +6,12 @@ RSpec.describe Projects::SnippetsController do
include Gitlab::Routing
let_it_be(:user) { create(:user) }
- let_it_be(:user2) { create(:user) }
- let(:project) { create(:project_empty_repo, :public) }
+ let_it_be(:other_user) { create(:user) }
+ let_it_be(:project) { create(:project_empty_repo, :public) }
before do
project.add_maintainer(user)
- project.add_maintainer(user2)
+ project.add_developer(other_user)
end
describe 'GET #index' do
@@ -47,7 +47,7 @@ RSpec.describe Projects::SnippetsController do
end
context 'when the project snippet is private' do
- let!(:project_snippet) { create(:project_snippet, :private, project: project, author: user) }
+ let_it_be(:project_snippet) { create(:project_snippet, :private, project: project, author: user) }
context 'when anonymous' do
it 'does not include the private snippet' do
@@ -59,11 +59,9 @@ RSpec.describe Projects::SnippetsController do
end
context 'when signed in as the author' do
- before do
+ it 'renders the snippet' do
sign_in(user)
- end
- it 'renders the snippet' do
subject
expect(assigns(:snippets)).to include(project_snippet)
@@ -72,11 +70,9 @@ RSpec.describe Projects::SnippetsController do
end
context 'when signed in as a project member' do
- before do
- sign_in(user2)
- end
-
it 'renders the snippet' do
+ sign_in(other_user)
+
subject
expect(assigns(:snippets)).to include(project_snippet)
@@ -171,7 +167,6 @@ RSpec.describe Projects::SnippetsController do
end
describe 'PUT #update' do
- let(:project) { create :project, :public }
let(:visibility_level) { Snippet::PUBLIC }
let(:snippet) { create :project_snippet, author: user, project: project, visibility_level: visibility_level }
@@ -190,20 +185,6 @@ RSpec.describe Projects::SnippetsController do
snippet.reload
end
- it_behaves_like 'updating snippet checks blob is binary' do
- let_it_be(:title) { 'Foo' }
- let(:params) do
- {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: snippet.id,
- project_snippet: { title: title }
- }
- end
-
- subject { put :update, params: params }
- end
-
context 'when the snippet is spam' do
before do
allow_next_instance_of(Spam::AkismetService) do |instance|
@@ -311,7 +292,7 @@ RSpec.describe Projects::SnippetsController do
end
describe 'POST #mark_as_spam' do
- let(:snippet) { create(:project_snippet, :private, project: project, author: user) }
+ let_it_be(:snippet) { create(:project_snippet, :private, project: project, author: user) }
before do
allow_next_instance_of(Spam::AkismetService) do |instance|
@@ -359,7 +340,7 @@ RSpec.describe Projects::SnippetsController do
%w[show raw].each do |action|
describe "GET ##{action}" do
context 'when the project snippet is private' do
- let(:project_snippet) { create(:project_snippet, :private, :repository, project: project, author: user) }
+ let_it_be(:project_snippet) { create(:project_snippet, :private, :repository, project: project, author: user) }
subject { get action, params: { namespace_id: project.namespace, project_id: project, id: project_snippet.to_param } }
@@ -381,7 +362,7 @@ RSpec.describe Projects::SnippetsController do
context 'when signed in as a project member' do
before do
- sign_in(user2)
+ sign_in(other_user)
end
it_behaves_like 'successful response'
@@ -494,9 +475,8 @@ RSpec.describe Projects::SnippetsController do
subject { get :raw, params: params }
context 'when repository is empty' do
- let(:content) { "first line\r\nsecond line\r\nthird line" }
- let(:formatted_content) { content.gsub(/\r\n/, "\n") }
- let(:project_snippet) do
+ let_it_be(:content) { "first line\r\nsecond line\r\nthird line" }
+ let_it_be(:project_snippet) do
create(
:project_snippet, :public, :empty_repo,
project: project,
@@ -505,6 +485,8 @@ RSpec.describe Projects::SnippetsController do
)
end
+ let(:formatted_content) { content.gsub(/\r\n/, "\n") }
+
context 'CRLF line ending' do
before do
allow_next_instance_of(Blob) do |instance|
@@ -531,7 +513,7 @@ RSpec.describe Projects::SnippetsController do
end
context 'when repository is not empty' do
- let(:project_snippet) do
+ let_it_be(:project_snippet) do
create(
:project_snippet, :public, :repository,
project: project,
@@ -553,7 +535,7 @@ RSpec.describe Projects::SnippetsController do
end
describe 'DELETE #destroy' do
- let!(:snippet) { create(:project_snippet, :private, project: project, author: user) }
+ let_it_be(:snippet) { create(:project_snippet, :private, project: project, author: user) }
let(:params) do
{
@@ -563,20 +545,22 @@ RSpec.describe Projects::SnippetsController do
}
end
+ subject { delete :destroy, params: params }
+
context 'when current user has ability to destroy the snippet' do
before do
sign_in(user)
end
it 'removes the snippet' do
- delete :destroy, params: params
+ subject
expect { snippet.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
context 'when snippet is succesfuly destroyed' do
it 'redirects to the project snippets page' do
- delete :destroy, params: params
+ subject
expect(response).to redirect_to(project_snippets_path(project))
end
@@ -589,7 +573,7 @@ RSpec.describe Projects::SnippetsController do
end
it 'renders the snippet page with errors' do
- delete :destroy, params: params
+ subject
expect(flash[:alert]).to eq('Failed to remove snippet.')
expect(response).to redirect_to(project_snippet_path(project, snippet))
@@ -598,32 +582,13 @@ RSpec.describe Projects::SnippetsController do
end
context 'when current_user does not have ability to destroy the snippet' do
- let(:another_user) { create(:user) }
-
- before do
- sign_in(another_user)
- end
-
it 'responds with status 404' do
- delete :destroy, params: params
+ sign_in(other_user)
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
+ subject
- describe 'GET #edit' do
- it_behaves_like 'editing snippet checks blob is binary' do
- let(:snippet) { create(:project_snippet, :private, project: project, author: user) }
- let(:params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- id: snippet
- }
+ expect(response).to have_gitlab_http_status(:not_found)
end
-
- subject { get :edit, params: params }
end
end
end
diff --git a/spec/controllers/projects/static_site_editor_controller_spec.rb b/spec/controllers/projects/static_site_editor_controller_spec.rb
index 384218504b9..7883c7e6f81 100644
--- a/spec/controllers/projects/static_site_editor_controller_spec.rb
+++ b/spec/controllers/projects/static_site_editor_controller_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe Projects::StaticSiteEditorController do
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:user) { create(:user) }
+ let(:data) { instance_double(Hash) }
describe 'GET show' do
let(:default_params) do
@@ -16,6 +17,16 @@ RSpec.describe Projects::StaticSiteEditorController do
}
end
+ let(:service_response) do
+ ServiceResponse.success(payload: data)
+ end
+
+ before do
+ allow_next_instance_of(::StaticSiteEditor::ConfigService) do |instance|
+ allow(instance).to receive(:execute).and_return(service_response)
+ end
+ end
+
context 'User roles' do
context 'anonymous' do
before do
@@ -55,18 +66,26 @@ RSpec.describe Projects::StaticSiteEditorController do
end
it 'assigns a required variables' do
- expect(assigns(:config)).to be_a(Gitlab::StaticSiteEditor::Config)
+ expect(assigns(:data)).to eq(data)
expect(assigns(:ref)).to eq('master')
expect(assigns(:path)).to eq('README.md')
end
- context 'when combination of ref and file path is incorrect' do
+ context 'when combination of ref and path is incorrect' do
let(:default_params) { super().merge(id: 'unknown') }
it 'responds with 404 page' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
+
+ context 'when invalid config file' do
+ let(:service_response) { ServiceResponse.error(message: 'invalid') }
+
+ it 'returns 422' do
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ end
+ end
end
end
end
diff --git a/spec/controllers/projects/todos_controller_spec.rb b/spec/controllers/projects/todos_controller_spec.rb
index e1e1e455094..0e35f401bc8 100644
--- a/spec/controllers/projects/todos_controller_spec.rb
+++ b/spec/controllers/projects/todos_controller_spec.rb
@@ -3,13 +3,14 @@
require('spec_helper')
RSpec.describe Projects::TodosController do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:design) { create(:design, project: project, issue: issue) }
let(:parent) { project }
- shared_examples 'project todos actions' do
+ shared_examples 'issuable todo actions' do
it_behaves_like 'todos actions'
context 'when not authorized for resource' do
@@ -40,7 +41,7 @@ RSpec.describe Projects::TodosController do
format: 'html'
end
- it_behaves_like 'project todos actions'
+ it_behaves_like 'issuable todo actions'
end
end
@@ -57,7 +58,31 @@ RSpec.describe Projects::TodosController do
format: 'html'
end
- it_behaves_like 'project todos actions'
+ it_behaves_like 'issuable todo actions'
+ end
+ end
+
+ context 'Designs' do
+ include DesignManagementTestHelpers
+
+ before do
+ enable_design_management
+ end
+
+ describe 'POST create' do
+ def post_create
+ post :create,
+ params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ issue_id: issue.id,
+ issuable_id: design.id,
+ issuable_type: 'design'
+ },
+ format: 'html'
+ end
+
+ it_behaves_like 'todos actions'
end
end
end
diff --git a/spec/controllers/projects/web_ide_schemas_controller_spec.rb b/spec/controllers/projects/web_ide_schemas_controller_spec.rb
new file mode 100644
index 00000000000..fbec941aecc
--- /dev/null
+++ b/spec/controllers/projects/web_ide_schemas_controller_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::WebIdeSchemasController do
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:project) { create(:project, :private, :repository, namespace: developer.namespace) }
+
+ before do
+ project.add_developer(developer)
+
+ sign_in(user)
+ end
+
+ describe 'GET show' do
+ let(:user) { developer }
+ let(:branch) { 'master' }
+
+ subject do
+ get :show, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ branch: branch,
+ filename: 'package.json'
+ }
+ end
+
+ before do
+ allow_next_instance_of(::Ide::SchemasConfigService) do |instance|
+ allow(instance).to receive(:execute).and_return(result)
+ end
+ end
+
+ context 'when branch is invalid' do
+ let(:branch) { 'non-existent' }
+
+ it 'returns 422' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ end
+ end
+
+ context 'when a valid schema exists' do
+ let(:result) { { status: :success, schema: { schema: 'Sample Schema' } } }
+
+ it 'returns the schema' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq('{"schema":"Sample Schema"}')
+ end
+ end
+
+ context 'when an error occurs parsing the schema' do
+ let(:result) { { status: :error, message: 'Some error occured' } }
+
+ it 'returns 422 with the error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(response.body).to eq('{"status":"error","message":"Some error occured"}')
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/web_ide_terminals_controller_spec.rb b/spec/controllers/projects/web_ide_terminals_controller_spec.rb
index 2ae5899c258..3eb3d5da351 100644
--- a/spec/controllers/projects/web_ide_terminals_controller_spec.rb
+++ b/spec/controllers/projects/web_ide_terminals_controller_spec.rb
@@ -113,7 +113,7 @@ RSpec.describe Projects::WebIdeTerminalsController do
let(:result) { { status: :success } }
before do
- allow_next_instance_of(::Ci::WebIdeConfigService) do |instance|
+ allow_next_instance_of(::Ide::TerminalConfigService) do |instance|
allow(instance).to receive(:execute).and_return(result)
end
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index e59493827ba..e4374a8f104 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -555,26 +555,55 @@ RSpec.describe ProjectsController do
end
shared_examples_for 'updating a project' do
+ context 'when there is a conflicting project path' do
+ let(:random_name) { "project-#{SecureRandom.hex(8)}" }
+ let!(:conflict_project) { create(:project, name: random_name, path: random_name, namespace: project.namespace) }
+
+ it 'does not show any references to the conflicting path' do
+ expect { update_project(path: random_name) }.not_to change { project.reload.path }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).not_to include(random_name)
+ end
+ end
+
context 'when only renaming a project path' do
- it "sets the repository to the right path after a rename" do
+ it "doesnt change the disk_path when using hashed storage" do
+ skip unless project.hashed_storage?(:repository)
+
+ hashed_storage_path = ::Storage::Hashed.new(project).disk_path
original_repository_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access do
project.repository.path
end
- expect { update_project path: 'renamed_path' }
- .to change { project.reload.path }
+ expect { update_project path: 'renamed_path' }.to change { project.reload.path }
expect(project.path).to include 'renamed_path'
assign_repository_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access do
assigns(:repository).path
end
- if project.hashed_storage?(:repository)
- expect(assign_repository_path).to eq(original_repository_path)
- else
- expect(assign_repository_path).to include(project.path)
+ expect(original_repository_path).to include(hashed_storage_path)
+ expect(assign_repository_path).to include(hashed_storage_path)
+ end
+
+ it "upgrades and move project to hashed storage when project was originally legacy" do
+ skip if project.hashed_storage?(:repository)
+
+ hashed_storage_path = Storage::Hashed.new(project).disk_path
+ original_repository_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access do
+ project.repository.path
+ end
+
+ expect { update_project path: 'renamed_path' }.to change { project.reload.path }
+ expect(project.path).to include 'renamed_path'
+
+ assign_repository_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access do
+ assigns(:repository).path
end
+ expect(original_repository_path).not_to include(hashed_storage_path)
+ expect(assign_repository_path).to include(hashed_storage_path)
expect(response).to have_gitlab_http_status(:found)
end
end
diff --git a/spec/controllers/registrations/experience_levels_controller_spec.rb b/spec/controllers/registrations/experience_levels_controller_spec.rb
index cd46cec1641..ee1acf3d93d 100644
--- a/spec/controllers/registrations/experience_levels_controller_spec.rb
+++ b/spec/controllers/registrations/experience_levels_controller_spec.rb
@@ -59,8 +59,6 @@ RSpec.describe Registrations::ExperienceLevelsController do
end
context 'when user is successfully updated' do
- it { is_expected.to set_flash[:message].to('Welcome! You have signed up successfully.') }
-
context 'when no experience_level is sent' do
before do
user.user_preference.update_attribute(:experience_level, :novice)
diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb
index 2c766035d87..f80e18df22e 100644
--- a/spec/controllers/registrations_controller_spec.rb
+++ b/spec/controllers/registrations_controller_spec.rb
@@ -128,7 +128,7 @@ RSpec.describe RegistrationsController do
post(:create, params: user_params)
expect(ActionMailer::Base.deliveries.last.to.first).to eq(user_params[:user][:email])
- expect(response).to redirect_to(dashboard_projects_path)
+ expect(response).to redirect_to(users_sign_up_welcome_path)
end
end
end
@@ -164,10 +164,10 @@ RSpec.describe RegistrationsController do
expect(flash[:alert]).to eq(_('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'))
end
- it 'redirects to the dashboard when the reCAPTCHA is solved' do
+ it 'redirects to the welcome page when the reCAPTCHA is solved' do
post(:create, params: user_params)
- expect(flash[:notice]).to eq(I18n.t('devise.registrations.signed_up'))
+ expect(response).to redirect_to(users_sign_up_welcome_path)
end
end
@@ -316,6 +316,12 @@ RSpec.describe RegistrationsController do
stub_experiment(signup_flow: true, terms_opt_in: true)
end
+ it 'records user for the terms_opt_in experiment' do
+ expect(controller).to receive(:record_experiment_user).with(:terms_opt_in)
+
+ subject
+ end
+
context 'when user is not part of the experiment' do
before do
stub_experiment_for_user(signup_flow: true, terms_opt_in: false)
@@ -458,42 +464,35 @@ RSpec.describe RegistrationsController do
describe '#welcome' do
subject { get :welcome }
- context 'signup_flow experiment enabled' do
- before do
- stub_experiment_for_user(signup_flow: true)
- end
+ it 'renders the devise_experimental_separate_sign_up_flow layout' do
+ sign_in(create(:user))
- it 'renders the devise_experimental_separate_sign_up_flow layout' do
- sign_in(create(:user))
+ expected_layout = Gitlab.ee? ? :checkout : :devise_experimental_separate_sign_up_flow
- expected_layout = Gitlab.ee? ? :checkout : :devise_experimental_separate_sign_up_flow
+ expect(subject).to render_template(expected_layout)
+ end
- expect(subject).to render_template(expected_layout)
+ context '2FA is required from group' do
+ before do
+ user = create(:user, require_two_factor_authentication_from_group: true)
+ sign_in(user)
end
- context '2FA is required from group' do
- before do
- user = create(:user, require_two_factor_authentication_from_group: true)
- sign_in(user)
- end
-
- it 'does not perform a redirect' do
- expect(subject).not_to redirect_to(profile_two_factor_auth_path)
- end
+ it 'does not perform a redirect' do
+ expect(subject).not_to redirect_to(profile_two_factor_auth_path)
end
end
+ end
- context 'signup_flow experiment disabled' do
- before do
- sign_in(create(:user))
- stub_experiment_for_user(signup_flow: false)
- end
-
- it 'renders the devise layout' do
- expected_layout = Gitlab.ee? ? :checkout : :devise
+ describe '#update_registration' do
+ subject(:update_registration) do
+ patch :update_registration, params: { user: { role: 'software_developer', setup_for_company: 'false' } }
+ end
- expect(subject).to render_template(expected_layout)
- end
+ before do
+ sign_in(create(:user))
end
+
+ it { is_expected.to redirect_to(dashboard_projects_path)}
end
end
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index a41ff28841d..f244392bbad 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -95,49 +95,77 @@ RSpec.describe SearchController do
using RSpec::Parameterized::TableSyntax
render_views
- it 'omits pipeline status from load' do
- project = create(:project, :public)
- expect(Gitlab::Cache::Ci::ProjectPipelineStatus).not_to receive(:load_in_batch_for_projects)
+ context 'when block_anonymous_global_searches is disabled' do
+ before do
+ stub_feature_flags(block_anonymous_global_searches: false)
+ end
- get :show, params: { scope: 'projects', search: project.name }
+ it 'omits pipeline status from load' do
+ project = create(:project, :public)
+ expect(Gitlab::Cache::Ci::ProjectPipelineStatus).not_to receive(:load_in_batch_for_projects)
- expect(assigns[:search_objects].first).to eq project
- end
+ get :show, params: { scope: 'projects', search: project.name }
- context 'check search term length' do
- let(:search_queries) do
- char_limit = SearchService::SEARCH_CHAR_LIMIT
- term_limit = SearchService::SEARCH_TERM_LIMIT
- {
- chars_under_limit: ('a' * (char_limit - 1)),
- chars_over_limit: ('a' * (char_limit + 1)),
- terms_under_limit: ('abc ' * (term_limit - 1)),
- terms_over_limit: ('abc ' * (term_limit + 1))
- }
+ expect(assigns[:search_objects].first).to eq project
end
- where(:string_name, :expectation) do
- :chars_under_limit | :not_to_set_flash
- :chars_over_limit | :set_chars_flash
- :terms_under_limit | :not_to_set_flash
- :terms_over_limit | :set_terms_flash
- end
+ context 'check search term length' do
+ let(:search_queries) do
+ char_limit = SearchService::SEARCH_CHAR_LIMIT
+ term_limit = SearchService::SEARCH_TERM_LIMIT
+ {
+ chars_under_limit: ('a' * (char_limit - 1)),
+ chars_over_limit: ('a' * (char_limit + 1)),
+ terms_under_limit: ('abc ' * (term_limit - 1)),
+ terms_over_limit: ('abc ' * (term_limit + 1))
+ }
+ end
+
+ where(:string_name, :expectation) do
+ :chars_under_limit | :not_to_set_flash
+ :chars_over_limit | :set_chars_flash
+ :terms_under_limit | :not_to_set_flash
+ :terms_over_limit | :set_terms_flash
+ end
- with_them do
- it do
- get :show, params: { scope: 'projects', search: search_queries[string_name] }
-
- case expectation
- when :not_to_set_flash
- expect(controller).not_to set_flash[:alert]
- when :set_chars_flash
- expect(controller).to set_flash[:alert].to(/characters/)
- when :set_terms_flash
- expect(controller).to set_flash[:alert].to(/terms/)
+ with_them do
+ it do
+ get :show, params: { scope: 'projects', search: search_queries[string_name] }
+
+ case expectation
+ when :not_to_set_flash
+ expect(controller).not_to set_flash[:alert]
+ when :set_chars_flash
+ expect(controller).to set_flash[:alert].to(/characters/)
+ when :set_terms_flash
+ expect(controller).to set_flash[:alert].to(/terms/)
+ end
end
end
end
end
+
+ context 'when block_anonymous_global_searches is enabled' do
+ context 'for unauthenticated user' do
+ before do
+ sign_out(user)
+ end
+
+ it 'redirects to login page' do
+ get :show, params: { scope: 'projects', search: '*' }
+
+ expect(response).to redirect_to new_user_session_path
+ end
+ end
+
+ context 'for authenticated user' do
+ it 'succeeds' do
+ get :show, params: { scope: 'projects', search: '*' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
end
it 'finds issue comments' do
@@ -149,6 +177,19 @@ RSpec.describe SearchController do
expect(assigns[:search_objects].first).to eq note
end
+ context 'unique users tracking' do
+ before do
+ allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
+ end
+
+ it_behaves_like 'tracking unique hll events', :search_track_unique_users do
+ subject { get :show, params: { scope: 'projects', search: 'term' }, format: format }
+
+ let(:target_id) { 'i_search_total' }
+ let(:expected_type) { instance_of(String) }
+ end
+ end
+
context 'on restricted projects' do
context 'when signed out' do
before do
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index f2e16baaccf..688539f2a03 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -6,11 +6,11 @@ RSpec.describe SessionsController do
include DeviseHelpers
include LdapHelpers
- describe '#new' do
- before do
- set_devise_mapping(context: @request)
- end
+ before do
+ set_devise_mapping(context: @request)
+ end
+ describe '#new' do
context 'when auto sign-in is enabled' do
before do
stub_omniauth_setting(auto_sign_in_with_provider: :saml)
@@ -59,13 +59,19 @@ RSpec.describe SessionsController do
end
end
end
- end
- describe '#create' do
- before do
- set_devise_mapping(context: @request)
+ it "redirects correctly for referer on same host with params" do
+ host = "test.host"
+ search_path = "/search?search=seed_project"
+ request.headers[:HTTP_REFERER] = "http://#{host}#{search_path}"
+
+ get(:new, params: { redirect_to_referer: :yes })
+
+ expect(controller.stored_location_for(:redirect)).to eq(search_path)
end
+ end
+ describe '#create' do
it_behaves_like 'known sign in' do
let(:user) { create(:user) }
let(:post_action) { post(:create, params: { user: { login: user.username, password: user.password } }) }
@@ -130,8 +136,13 @@ RSpec.describe SessionsController do
end
it 'creates an audit log record' do
- expect { post(:create, params: { user: user_params }) }.to change { SecurityEvent.count }.by(1)
- expect(SecurityEvent.last.details[:with]).to eq('standard')
+ expect { post(:create, params: { user: user_params }) }.to change { AuditEvent.count }.by(1)
+ expect(AuditEvent.last.details[:with]).to eq('standard')
+ end
+
+ it 'creates an authentication event record' do
+ expect { post(:create, params: { user: user_params }) }.to change { AuthenticationEvent.count }.by(1)
+ expect(AuthenticationEvent.last.provider).to eq('standard')
end
include_examples 'user login request with unique ip limit', 302 do
@@ -229,7 +240,7 @@ RSpec.describe SessionsController do
context 'when there are more than 5 anonymous session with the same IP' do
before do
- allow(Gitlab::AnonymousSession).to receive_message_chain(:new, :stored_sessions).and_return(6)
+ allow(Gitlab::AnonymousSession).to receive_message_chain(:new, :session_count).and_return(6)
end
it 'displays an error when the reCAPTCHA is not solved' do
@@ -241,7 +252,7 @@ RSpec.describe SessionsController do
end
it 'successfully logs in a user when reCAPTCHA is solved' do
- expect(Gitlab::AnonymousSession).to receive_message_chain(:new, :cleanup_session_per_ip_entries)
+ expect(Gitlab::AnonymousSession).to receive_message_chain(:new, :cleanup_session_per_ip_count)
succesful_login(user_params)
@@ -398,8 +409,13 @@ RSpec.describe SessionsController do
end
it "creates an audit log record" do
- expect { authenticate_2fa(login: user.username, otp_attempt: user.current_otp) }.to change { SecurityEvent.count }.by(1)
- expect(SecurityEvent.last.details[:with]).to eq("two-factor")
+ expect { authenticate_2fa(login: user.username, otp_attempt: user.current_otp) }.to change { AuditEvent.count }.by(1)
+ expect(AuditEvent.last.details[:with]).to eq("two-factor")
+ end
+
+ it "creates an authentication event record" do
+ expect { authenticate_2fa(login: user.username, otp_attempt: user.current_otp) }.to change { AuthenticationEvent.count }.by(1)
+ expect(AuthenticationEvent.last.provider).to eq("two-factor")
end
end
@@ -410,6 +426,10 @@ RSpec.describe SessionsController do
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)
@@ -435,31 +455,21 @@ RSpec.describe SessionsController do
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 { SecurityEvent.count }.by(1)
- expect(SecurityEvent.last.details[:with]).to eq("two-factor-via-u2f-device")
+ 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")
end
- end
- end
-
- describe "#new" do
- before do
- set_devise_mapping(context: @request)
- end
- it "redirects correctly for referer on same host with params" do
- host = "test.host"
- search_path = "/search?search=seed_project"
- request.headers[:HTTP_REFERER] = "http://#{host}#{search_path}"
-
- get(:new, params: { redirect_to_referer: :yes })
+ it "creates an authentication event record" do
+ allow(U2fRegistration).to receive(:authenticate).and_return(true)
- expect(controller.stored_location_for(:redirect)).to eq(search_path)
+ 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")
+ end
end
end
context 'when login fails' do
before do
- set_devise_mapping(context: @request)
@request.env["warden.options"] = { action: 'unauthenticated' }
end
@@ -473,10 +483,6 @@ RSpec.describe SessionsController do
describe '#set_current_context' do
let_it_be(:user) { create(:user) }
- before do
- set_devise_mapping(context: @request)
- end
-
context 'when signed in' do
before do
sign_in(user)
@@ -530,4 +536,21 @@ RSpec.describe SessionsController do
end
end
end
+
+ describe '#destroy' do
+ before do
+ sign_in(user)
+ end
+
+ context 'for a user whose password has expired' do
+ let(:user) { create(:user, password_expires_at: 2.days.ago) }
+
+ it 'allows to sign out successfully' do
+ delete :destroy
+
+ expect(response).to redirect_to(new_user_session_path)
+ expect(controller.current_user).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb
index 92370b3381a..6517922d92a 100644
--- a/spec/controllers/snippets_controller_spec.rb
+++ b/spec/controllers/snippets_controller_spec.rb
@@ -334,12 +334,6 @@ RSpec.describe SnippetsController do
snippet.reload
end
- it_behaves_like 'updating snippet checks blob is binary' do
- let_it_be(:title) { 'Foo' }
-
- subject { put :update, params: { id: snippet, personal_snippet: { title: title } } }
- end
-
context 'when the snippet is spam' do
before do
allow_next_instance_of(Spam::AkismetService) do |instance|
@@ -746,12 +740,4 @@ RSpec.describe SnippetsController do
end
end
end
-
- describe 'GET #edit' do
- it_behaves_like 'editing snippet checks blob is binary' do
- let_it_be(:snippet) { create(:personal_snippet, :public, :repository, author: user) }
-
- subject { get :edit, params: { id: snippet } }
- end
- end
end
diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb
index 99c3b82bd0d..bec4b24484a 100644
--- a/spec/controllers/users_controller_spec.rb
+++ b/spec/controllers/users_controller_spec.rb
@@ -114,71 +114,6 @@ RSpec.describe UsersController do
end
end
- describe "#ssh_keys" do
- describe "non existent user" do
- it "does not generally work" do
- get :ssh_keys, params: { username: 'not-existent' }
-
- expect(response).not_to be_successful
- end
- end
-
- describe "user with no keys" do
- it "does generally work" do
- get :ssh_keys, params: { username: user.username }
-
- expect(response).to be_successful
- end
-
- it "renders all keys separated with a new line" do
- get :ssh_keys, params: { username: user.username }
-
- expect(response.body).to eq("")
- end
-
- it "responds with text/plain content type" do
- get :ssh_keys, params: { username: user.username }
- expect(response.content_type).to eq("text/plain")
- end
- end
-
- describe "user with keys" do
- let!(:key) { create(:key, user: user) }
- let!(:another_key) { create(:another_key, user: user) }
- let!(:deploy_key) { create(:deploy_key, user: user) }
-
- it "does generally work" do
- get :ssh_keys, params: { username: user.username }
-
- expect(response).to be_successful
- end
-
- it "renders all non deploy keys separated with a new line" do
- get :ssh_keys, params: { username: user.username }
-
- expect(response.body).not_to eq('')
- expect(response.body).to eq(user.all_ssh_keys.join("\n"))
-
- expect(response.body).to include(key.key.sub(' dummy@gitlab.com', ''))
- expect(response.body).to include(another_key.key.sub(' dummy@gitlab.com', ''))
-
- expect(response.body).not_to include(deploy_key.key)
- end
-
- it "does not render the comment of the key" do
- get :ssh_keys, params: { username: user.username }
-
- expect(response.body).not_to match(/dummy@gitlab.com/)
- end
-
- it "responds with text/plain content type" do
- get :ssh_keys, params: { username: user.username }
-
- expect(response.content_type).to eq("text/plain")
- end
- end
- end
-
describe 'GET #calendar' do
context 'for user' do
let(:project) { create(:project) }
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index 1c9167ef025..69cd08d82e1 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -18,8 +18,8 @@ RSpec.describe 'Database schema' do
approvals: %w[user_id],
approver_groups: %w[target_id],
approvers: %w[target_id user_id],
- audit_events: %w[author_id entity_id],
- audit_events_part_5fc467ac26: %w[author_id entity_id],
+ audit_events: %w[author_id entity_id target_id],
+ audit_events_part_5fc467ac26: %w[author_id entity_id target_id],
award_emoji: %w[awardable_id user_id],
aws_roles: %w[role_external_id],
boards: %w[milestone_id],
@@ -35,7 +35,6 @@ RSpec.describe 'Database schema' do
deploy_keys_projects: %w[deploy_key_id],
deployments: %w[deployable_id environment_id user_id],
draft_notes: %w[discussion_id commit_id],
- emails: %w[user_id],
epics: %w[updated_by_id last_edited_by_id state_id],
events: %w[target_id],
forked_project_links: %w[forked_from_project_id],
diff --git a/spec/docs_screenshots/container_registry_docs.rb b/spec/docs_screenshots/container_registry_docs.rb
new file mode 100644
index 00000000000..7e533bdf1d7
--- /dev/null
+++ b/spec/docs_screenshots/container_registry_docs.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Container Registry', :js do
+ include DocsScreenshotHelpers
+
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, namespace: group) }
+
+ before do
+ page.driver.browser.manage.window.resize_to(1366, 1024)
+
+ group.add_owner(user)
+ sign_in(user)
+
+ stub_container_registry_config(enabled: true)
+ stub_container_registry_tags(repository: :any, tags: [])
+ end
+
+ context 'expiration policy settings' do
+ it 'user/packages/container_registry/img/expiration_policy_form' do
+ visit project_settings_ci_cd_path(project)
+ screenshot_area = find('#js-registry-policies')
+ scroll_to screenshot_area
+ expect(screenshot_area).to have_content 'Expiration interval'
+ set_crop_data(screenshot_area, 20)
+ end
+ end
+
+ context 'project container_registry' do
+ it 'user/packages/container_registry/img/project_empty_page' do
+ visit_project_container_registry
+
+ expect(page).to have_content _('There are no container images stored for this project')
+ end
+
+ context 'with a list of repositories' do
+ before do
+ stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true)
+ create_list(:container_repository, 12, project: project)
+ end
+
+ it 'user/packages/container_registry/img/project_image_repositories_list' do
+ visit_project_container_registry
+
+ expect(page).to have_content 'Image Repositories'
+ end
+
+ it 'user/packages/container_registry/img/project_image_repositories_list_with_commands_open' do
+ visit_project_container_registry
+
+ click_on 'CLI Commands'
+ end
+ end
+ end
+
+ context 'group container_registry' do
+ it 'user/packages/container_registry/img/group_empty_page' do
+ visit_group_container_registry
+
+ expect(page).to have_content 'There are no container images available in this group'
+ end
+
+ context 'with a list of repositories' do
+ before do
+ stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true)
+ create_list(:container_repository, 12, project: project)
+ end
+
+ it 'user/packages/container_registry/img/group_image_repositories_list' do
+ visit_group_container_registry
+
+ expect(page).to have_content 'Image Repositories'
+ end
+ end
+ end
+
+ def visit_project_container_registry
+ visit project_container_registry_index_path(project)
+ end
+
+ def visit_group_container_registry
+ visit group_container_registries_path(group)
+ end
+end
diff --git a/spec/factories/atlassian_identities.rb b/spec/factories/atlassian_identities.rb
new file mode 100644
index 00000000000..698cf4ae7ad
--- /dev/null
+++ b/spec/factories/atlassian_identities.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :atlassian_identity, class: 'Atlassian::Identity' do
+ extern_uid { generate(:username) }
+ user { create(:user) }
+ expires_at { 2.weeks.from_now }
+ token { SecureRandom.alphanumeric(1254) }
+ refresh_token { SecureRandom.alphanumeric(45) }
+ end
+end
diff --git a/spec/factories/audit_events.rb b/spec/factories/audit_events.rb
index 38414400282..5497648273c 100644
--- a/spec/factories/audit_events.rb
+++ b/spec/factories/audit_events.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :audit_event, class: 'SecurityEvent', aliases: [:user_audit_event] do
+ factory :audit_event, class: 'AuditEvent', aliases: [:user_audit_event] do
user
transient { target_user { create(:user) } }
@@ -36,7 +36,7 @@ FactoryBot.define do
ip_address { IPAddr.new '127.0.0.1' }
details do
{
- change: 'packges_enabled',
+ change: 'packages_enabled',
from: true,
to: false,
author_name: user.name,
diff --git a/spec/factories/ci/bridge.rb b/spec/factories/ci/bridge.rb
index 4c1d5f07a42..5a33a30921b 100644
--- a/spec/factories/ci/bridge.rb
+++ b/spec/factories/ci/bridge.rb
@@ -53,5 +53,14 @@ FactoryBot.define do
finished
status { 'failed' }
end
+
+ trait :skipped do
+ started
+ status { 'skipped' }
+ end
+
+ trait :strategy_depend do
+ options { { trigger: { strategy: 'depend' } } }
+ end
end
end
diff --git a/spec/factories/ci/build_pending_states.rb b/spec/factories/ci/build_pending_states.rb
new file mode 100644
index 00000000000..765b7f005b9
--- /dev/null
+++ b/spec/factories/ci/build_pending_states.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_build_pending_state, class: 'Ci::BuildPendingState' do
+ build factory: :ci_build
+ trace_checksum { 'crc32:12345678' }
+ state { 'success' }
+ end
+end
diff --git a/spec/factories/ci/pipeline_artifacts.rb b/spec/factories/ci/pipeline_artifacts.rb
index ecfd1e79e78..fa33609dd6c 100644
--- a/spec/factories/ci/pipeline_artifacts.rb
+++ b/spec/factories/ci/pipeline_artifacts.rb
@@ -6,12 +6,33 @@ FactoryBot.define do
project { pipeline.project }
file_type { :code_coverage }
file_format { :raw }
- file_store { Ci::PipelineArtifact::FILE_STORE_SUPPORTED.first }
+ file_store { ObjectStorage::SUPPORTED_STORES.first }
size { 1.megabytes }
after(:build) do |artifact, _evaluator|
artifact.file = fixture_file_upload(
Rails.root.join('spec/fixtures/pipeline_artifacts/code_coverage.json'), 'application/json')
end
+
+ trait :with_multibyte_characters do
+ size { { "utf8" => "✓" }.to_json.bytesize }
+ after(:build) do |artifact, _evaluator|
+ artifact.file = CarrierWaveStringFile.new_file(
+ file_content: { "utf8" => "✓" }.to_json,
+ filename: 'filename',
+ content_type: 'application/json'
+ )
+ end
+ end
+
+ trait :with_code_coverage_with_multiple_files do
+ after(:build) do |artifact, _evaluator|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/pipeline_artifacts/code_coverage_with_multiple_files.json'), 'application/json'
+ )
+ end
+
+ size { file.size }
+ end
end
end
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index 2790be8b70d..6174bfbfbb7 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -121,6 +121,12 @@ FactoryBot.define do
end
end
+ trait :with_coverage_report_artifact do
+ after(:build) do |pipeline, evaluator|
+ pipeline.pipeline_artifacts << build(:ci_pipeline_artifact, pipeline: pipeline, project: pipeline.project)
+ end
+ end
+
trait :with_terraform_reports do
status { :success }
diff --git a/spec/factories/ci_platform_metrics.rb b/spec/factories/ci_platform_metrics.rb
new file mode 100644
index 00000000000..478f9715021
--- /dev/null
+++ b/spec/factories/ci_platform_metrics.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_platform_metric do
+ recorded_at { Time.zone.now }
+ platform_target { generate(:title) }
+ count { SecureRandom.random_number(100) }
+ end
+end
diff --git a/spec/factories/clusters/kubernetes_namespaces.rb b/spec/factories/clusters/kubernetes_namespaces.rb
index c820bf4da60..efcb3abcb90 100644
--- a/spec/factories/clusters/kubernetes_namespaces.rb
+++ b/spec/factories/clusters/kubernetes_namespaces.rb
@@ -10,15 +10,18 @@ FactoryBot.define do
if cluster.project_type?
cluster_project = cluster.cluster_project
- kubernetes_namespace.project = cluster_project.project
+ kubernetes_namespace.project = cluster_project&.project
kubernetes_namespace.cluster_project = cluster_project
end
- kubernetes_namespace.namespace ||=
- Gitlab::Kubernetes::DefaultNamespace.new(
- cluster,
- project: kubernetes_namespace.project
- ).from_environment_slug(kubernetes_namespace.environment&.slug)
+ if kubernetes_namespace.project
+ kubernetes_namespace.namespace ||=
+ Gitlab::Kubernetes::DefaultNamespace.new(
+ cluster,
+ project: kubernetes_namespace.project
+ ).from_environment_slug(kubernetes_namespace.environment&.slug)
+ end
+
kubernetes_namespace.service_account_name ||= "#{kubernetes_namespace.namespace}-service-account"
end
diff --git a/spec/factories/clusters/providers/aws.rb b/spec/factories/clusters/providers/aws.rb
index 2c54300e606..497181de89a 100644
--- a/spec/factories/clusters/providers/aws.rb
+++ b/spec/factories/clusters/providers/aws.rb
@@ -4,6 +4,7 @@ FactoryBot.define do
factory :cluster_provider_aws, class: 'Clusters::Providers::Aws' do
association :cluster, platform_type: :kubernetes, provider_type: :aws
+ kubernetes_version { '1.16' }
role_arn { 'arn:aws:iam::123456789012:role/role-name' }
vpc_id { 'vpc-00000000000000000' }
subnet_ids { %w(subnet-00000000000000000 subnet-11111111111111111) }
diff --git a/spec/factories/dev_ops_score_metrics.rb b/spec/factories/dev_ops_report_metrics.rb
index 1d1f1a2c39e..808c70c2499 100644
--- a/spec/factories/dev_ops_score_metrics.rb
+++ b/spec/factories/dev_ops_report_metrics.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :dev_ops_score_metric, class: 'DevOpsScore::Metric' do
+ factory :dev_ops_report_metric, class: 'DevOpsReport::Metric' do
leader_issues { 9.256 }
instance_issues { 1.234 }
percentage_issues { 13.331 }
diff --git a/spec/factories/diff_position.rb b/spec/factories/diff_position.rb
index 685272acf5c..0185c4ce156 100644
--- a/spec/factories/diff_position.rb
+++ b/spec/factories/diff_position.rb
@@ -53,7 +53,10 @@ FactoryBot.define do
factory :image_diff_position do
position_type { 'image' }
x { 1 }
- y { 1 }
+ # Fix:
+ # NoMethodError: undefined method `end_line=' for nil:NilClass
+ # from /usr/lib/ruby/2.6.0/psych/tree_builder.rb:133:in `set_end_location'
+ add_attribute(:y) { 1 }
width { 10 }
height { 10 }
end
diff --git a/spec/factories/draft_note.rb b/spec/factories/draft_note.rb
index 24563dc92b7..67a3377a39f 100644
--- a/spec/factories/draft_note.rb
+++ b/spec/factories/draft_note.rb
@@ -25,7 +25,7 @@ FactoryBot.define do
factory :draft_note_on_discussion, traits: [:on_discussion]
trait :on_discussion do
- discussion_id { create(:discussion_note_on_merge_request, noteable: merge_request, project: project).discussion_id }
+ discussion_id { association(:discussion_note_on_merge_request, noteable: merge_request, project: project).discussion_id }
end
end
end
diff --git a/spec/factories/file_uploaders.rb b/spec/factories/file_uploaders.rb
index dc888fdd535..f7ceb800f14 100644
--- a/spec/factories/file_uploaders.rb
+++ b/spec/factories/file_uploaders.rb
@@ -14,7 +14,7 @@ FactoryBot.define do
end
after(:build) do |uploader, evaluator|
- uploader.store!(evaluator.file)
+ uploader.store!(evaluator.file) if evaluator.project&.persisted?
end
initialize_with do
diff --git a/spec/factories/group_members.rb b/spec/factories/group_members.rb
index 3c9d469f23c..37ddbc09616 100644
--- a/spec/factories/group_members.rb
+++ b/spec/factories/group_members.rb
@@ -3,7 +3,7 @@
FactoryBot.define do
factory :group_member do
access_level { GroupMember::OWNER }
- group
+ source { association(:group) }
user
trait(:guest) { access_level { GroupMember::GUEST } }
@@ -28,5 +28,11 @@ FactoryBot.define do
trait :blocked do
after(:build) { |group_member, _| group_member.user.block! }
end
+
+ trait :minimal_access do
+ to_create { |instance| instance.save!(validate: false) }
+
+ access_level { GroupMember::MINIMAL_ACCESS }
+ end
end
end
diff --git a/spec/factories/instance_statistics/measurement.rb b/spec/factories/instance_statistics/measurement.rb
new file mode 100644
index 00000000000..fb180c23214
--- /dev/null
+++ b/spec/factories/instance_statistics/measurement.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :instance_statistics_measurement, class: 'Analytics::InstanceStatistics::Measurement' do
+ recorded_at { Time.now }
+ identifier { :projects }
+ count { 1_000 }
+
+ trait :project_count do
+ identifier { :projects }
+ end
+
+ trait :group_count do
+ identifier { :groups }
+ end
+ end
+end
diff --git a/spec/factories/issuable_severity.rb b/spec/factories/issuable_severity.rb
new file mode 100644
index 00000000000..10ce58f7944
--- /dev/null
+++ b/spec/factories/issuable_severity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :issuable_severity do
+ association :issue, factory: :incident
+ end
+end
diff --git a/spec/factories/issue_links.rb b/spec/factories/issue_links.rb
new file mode 100644
index 00000000000..884e4dfac08
--- /dev/null
+++ b/spec/factories/issue_links.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :issue_link do
+ source factory: :issue
+ target factory: :issue
+ end
+end
diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb
index 99fe2ef9c0a..90e43b9e22c 100644
--- a/spec/factories/issues.rb
+++ b/spec/factories/issues.rb
@@ -26,6 +26,12 @@ FactoryBot.define do
closed_at { Time.now }
end
+ trait :with_alert do
+ after(:create) do |issue|
+ create(:alert_management_alert, project: issue.project, issue: issue)
+ end
+ end
+
after(:build) do |issue, evaluator|
issue.state_id = Issue.available_states[evaluator.state]
end
@@ -46,5 +52,9 @@ FactoryBot.define do
factory :incident do
issue_type { :incident }
end
+
+ factory :quality_test_case do
+ issue_type { :test_case }
+ end
end
end
diff --git a/spec/factories/jira_connect_installation.rb b/spec/factories/jira_connect_installation.rb
new file mode 100644
index 00000000000..2e3202c662c
--- /dev/null
+++ b/spec/factories/jira_connect_installation.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :jira_connect_installation do
+ sequence(:client_key) { |n| "atlassian-client-key-#{n}" }
+ shared_secret { 'jrNarHaRYaumMvfV3UnYpwt8' }
+ base_url { 'https://sample.atlassian.net' }
+ end
+end
diff --git a/spec/factories/jira_connect_subscription.rb b/spec/factories/jira_connect_subscription.rb
new file mode 100644
index 00000000000..e22b277f190
--- /dev/null
+++ b/spec/factories/jira_connect_subscription.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :jira_connect_subscription do
+ association :installation, factory: :jira_connect_installation
+ association :namespace, factory: :group
+ end
+end
diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb
index 6725b571f19..a9a9416c48b 100644
--- a/spec/factories/labels.rb
+++ b/spec/factories/labels.rb
@@ -18,6 +18,13 @@ FactoryBot.define do
title { "#{prefix}::#{generate(:label_title)}" }
end
+ trait :incident do
+ properties = IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES
+ title { properties.fetch(:title) }
+ description { properties.fetch(:description) }
+ color { properties.fetch(:color) }
+ end
+
factory :label, traits: [:base_label], class: 'ProjectLabel' do
project
diff --git a/spec/factories/merge_request_diffs.rb b/spec/factories/merge_request_diffs.rb
index 0c4c3244af5..fdb7f52f3bd 100644
--- a/spec/factories/merge_request_diffs.rb
+++ b/spec/factories/merge_request_diffs.rb
@@ -2,12 +2,26 @@
FactoryBot.define do
factory :merge_request_diff do
- association :merge_request
+ merge_request do
+ build(:merge_request) do |merge_request|
+ # MergeRequest should not create a MergeRequestDiff in the callback
+ allow(merge_request).to receive(:ensure_merge_request_diff)
+ end
+ end
+
state { :collected }
commits_count { 1 }
base_commit_sha { Digest::SHA1.hexdigest(SecureRandom.hex) }
head_commit_sha { Digest::SHA1.hexdigest(SecureRandom.hex) }
start_commit_sha { Digest::SHA1.hexdigest(SecureRandom.hex) }
+
+ trait :external do
+ external_diff { fixture_file_upload("spec/fixtures/doc_sample.txt", "plain/txt") }
+ stored_externally { true }
+ importing { true } # this avoids setting the state to 'empty'
+ end
+
+ factory :external_merge_request_diff, traits: [:external]
end
end
diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb
index af6e88f73b1..6836d5d71f0 100644
--- a/spec/factories/merge_requests.rb
+++ b/spec/factories/merge_requests.rb
@@ -169,7 +169,7 @@ FactoryBot.define do
merge_request.head_pipeline = build(
:ci_pipeline,
:success,
- :with_coverage_reports,
+ :with_coverage_report_artifact,
project: merge_request.source_project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha)
diff --git a/spec/factories/metrics/users_starred_dasboards.rb b/spec/factories/metrics/users_starred_dashboards.rb
index 06fe7735e9a..06fe7735e9a 100644
--- a/spec/factories/metrics/users_starred_dasboards.rb
+++ b/spec/factories/metrics/users_starred_dashboards.rb
diff --git a/spec/factories/operations/feature_flag_scopes.rb b/spec/factories/operations/feature_flag_scopes.rb
new file mode 100644
index 00000000000..a98c397b8b5
--- /dev/null
+++ b/spec/factories/operations/feature_flag_scopes.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :operations_feature_flag_scope, class: 'Operations::FeatureFlagScope' do
+ association :feature_flag, factory: :operations_feature_flag
+ active { true }
+ strategies { [{ name: "default", parameters: {} }] }
+ sequence(:environment_scope) { |n| "review/patch-#{n}" }
+ end
+end
diff --git a/spec/factories/operations/feature_flags.rb b/spec/factories/operations/feature_flags.rb
new file mode 100644
index 00000000000..7e43d38a04f
--- /dev/null
+++ b/spec/factories/operations/feature_flags.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :operations_feature_flag, class: 'Operations::FeatureFlag' do
+ sequence(:name) { |n| "feature_flag_#{n}" }
+ project
+ active { true }
+
+ trait :legacy_flag do
+ version { Operations::FeatureFlag.versions['legacy_flag'] }
+ end
+
+ trait :new_version_flag do
+ version { Operations::FeatureFlag.versions['new_version_flag'] }
+ end
+ end
+end
diff --git a/spec/factories/operations/feature_flags/scope.rb b/spec/factories/operations/feature_flags/scope.rb
new file mode 100644
index 00000000000..ef0097c6d08
--- /dev/null
+++ b/spec/factories/operations/feature_flags/scope.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :operations_scope, class: 'Operations::FeatureFlags::Scope' do
+ association :strategy, factory: :operations_strategy
+ sequence(:environment_scope) { |n| "review/patch-#{n}" }
+ end
+end
diff --git a/spec/factories/operations/feature_flags/strategy.rb b/spec/factories/operations/feature_flags/strategy.rb
new file mode 100644
index 00000000000..bdb5d9f0f3c
--- /dev/null
+++ b/spec/factories/operations/feature_flags/strategy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :operations_strategy, class: 'Operations::FeatureFlags::Strategy' do
+ association :feature_flag, factory: :operations_feature_flag
+ name { "default" }
+ parameters { {} }
+ end
+end
diff --git a/spec/factories/operations/feature_flags/user_list.rb b/spec/factories/operations/feature_flags/user_list.rb
new file mode 100644
index 00000000000..e87598f0d7c
--- /dev/null
+++ b/spec/factories/operations/feature_flags/user_list.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :operations_feature_flag_user_list, class: 'Operations::FeatureFlags::UserList' do
+ association :project, factory: :project
+ name { 'My User List' }
+ user_xids { 'user1,user2,user3' }
+ end
+end
diff --git a/spec/factories/operations/feature_flags_clients.rb b/spec/factories/operations/feature_flags_clients.rb
new file mode 100644
index 00000000000..ca9a28dcfed
--- /dev/null
+++ b/spec/factories/operations/feature_flags_clients.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :operations_feature_flags_client, class: 'Operations::FeatureFlagsClient' do
+ project
+ end
+end
diff --git a/spec/factories/packages.rb b/spec/factories/packages.rb
index a7902f6f105..52b2a32cd3b 100644
--- a/spec/factories/packages.rb
+++ b/spec/factories/packages.rb
@@ -2,6 +2,7 @@
FactoryBot.define do
factory :package, class: 'Packages::Package' do
project
+ creator { project&.creator }
name { 'my/company/app/my-app' }
sequence(:version) { |n| "1.#{n}-SNAPSHOT" }
package_type { :maven }
@@ -56,14 +57,20 @@ FactoryBot.define do
end
factory :pypi_package do
- pypi_metadatum
-
sequence(:name) { |n| "pypi-package-#{n}"}
sequence(:version) { |n| "1.0.#{n}" }
package_type { :pypi }
- after :create do |package|
+ transient do
+ without_loaded_metadatum { false }
+ end
+
+ after :create do |package, evaluator|
create :package_file, :pypi, package: package, file_name: "#{package.name}-#{package.version}.tar.gz"
+
+ unless evaluator.without_loaded_metadatum
+ create :pypi_metadatum, package: package
+ end
end
end
@@ -115,6 +122,12 @@ FactoryBot.define do
conan_metadatum { build(:conan_metadatum, package: nil) }
end
end
+
+ factory :generic_package do
+ sequence(:name) { |n| "generic-package-#{n}" }
+ version { '1.0.0' }
+ package_type { :generic }
+ end
end
factory :composer_metadatum, class: 'Packages::Composer::Metadatum' do
@@ -297,7 +310,7 @@ FactoryBot.define do
end
factory :pypi_metadatum, class: 'Packages::Pypi::Metadatum' do
- association :package, package_type: :pypi
+ package { create(:pypi_package, without_loaded_metadatum: true) }
required_python { '>=2.7' }
end
diff --git a/spec/factories/pages_deployments.rb b/spec/factories/pages_deployments.rb
new file mode 100644
index 00000000000..1bea003d683
--- /dev/null
+++ b/spec/factories/pages_deployments.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :pages_deployment, class: 'PagesDeployment' do
+ project
+ file_store { ObjectStorage::SUPPORTED_STORES.first }
+ size { 1.megabytes }
+
+ # TODO: replace with proper file uploaded in https://gitlab.com/gitlab-org/gitlab/-/issues/245295
+ file { "dummy string" }
+ end
+end
diff --git a/spec/factories/plan_limits.rb b/spec/factories/plan_limits.rb
index 4aea09618d0..ae892307193 100644
--- a/spec/factories/plan_limits.rb
+++ b/spec/factories/plan_limits.rb
@@ -7,5 +7,14 @@ FactoryBot.define do
trait :default_plan do
plan factory: :default_plan
end
+
+ trait :with_package_file_sizes do
+ conan_max_file_size { 100 }
+ maven_max_file_size { 100 }
+ npm_max_file_size { 100 }
+ nuget_max_file_size { 100 }
+ pypi_max_file_size { 100 }
+ generic_packages_max_file_size { 100 }
+ end
end
end
diff --git a/spec/factories/project_feature_usage.rb b/spec/factories/project_feature_usage.rb
new file mode 100644
index 00000000000..8265ea04392
--- /dev/null
+++ b/spec/factories/project_feature_usage.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :project_feature_usage do
+ project
+
+ trait :dvcs_cloud do
+ jira_dvcs_cloud_last_sync_at { Time.current }
+ end
+
+ trait :dvcs_server do
+ jira_dvcs_server_last_sync_at { Time.current }
+ end
+ end
+end
diff --git a/spec/factories/project_members.rb b/spec/factories/project_members.rb
index e7004937be3..0c2ffac4112 100644
--- a/spec/factories/project_members.rb
+++ b/spec/factories/project_members.rb
@@ -3,7 +3,7 @@
FactoryBot.define do
factory :project_member do
user
- project
+ source { association(:project) }
maintainer
trait(:guest) { access_level { ProjectMember::GUEST } }
diff --git a/spec/factories/project_statistics.rb b/spec/factories/project_statistics.rb
index 78e80a92b3a..ea003b67db0 100644
--- a/spec/factories/project_statistics.rb
+++ b/spec/factories/project_statistics.rb
@@ -22,6 +22,7 @@ FactoryBot.define do
project_statistics.build_artifacts_size = evaluator.size_multiplier * 4
project_statistics.packages_size = evaluator.size_multiplier * 5
project_statistics.snippets_size = evaluator.size_multiplier * 6
+ project_statistics.pipeline_artifacts_size = evaluator.size_multiplier * 7
end
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 328b7f9a229..e3411e4f925 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -3,8 +3,6 @@
require_relative '../support/helpers/test_env'
FactoryBot.define do
- PAGES_ACCESS_LEVEL_SCHEMA_VERSION ||= 20180423204600
-
# Project without repository
#
# Project does not have bare repository.
@@ -42,7 +40,7 @@ FactoryBot.define do
forward_deployment_enabled { nil }
end
- after(:create) do |project, evaluator|
+ before(:create) do |project, evaluator|
# Builds and MRs can't have higher visibility level than repository access level.
builds_access_level = [evaluator.builds_access_level, evaluator.repository_access_level].min
merge_requests_access_level = [evaluator.merge_requests_access_level, evaluator.repository_access_level].min
@@ -54,15 +52,14 @@ FactoryBot.define do
issues_access_level: evaluator.issues_access_level,
forking_access_level: evaluator.forking_access_level,
merge_requests_access_level: merge_requests_access_level,
- repository_access_level: evaluator.repository_access_level
+ repository_access_level: evaluator.repository_access_level,
+ pages_access_level: evaluator.pages_access_level
}
- if ActiveRecord::Migrator.current_version >= PAGES_ACCESS_LEVEL_SCHEMA_VERSION
- hash.store("pages_access_level", evaluator.pages_access_level)
- end
-
- project.project_feature.update!(hash)
+ project.build_project_feature(hash)
+ end
+ after(:create) do |project, evaluator|
# Normally the class Projects::CreateService is used for creating
# projects, and this class takes care of making sure the owner and current
# user have access to the project. Our specs don't use said service class,
@@ -114,6 +111,18 @@ FactoryBot.define do
import_status { :failed }
end
+ trait :jira_dvcs_cloud do
+ before(:create) do |project|
+ create(:project_feature_usage, :dvcs_cloud, project: project)
+ end
+ end
+
+ trait :jira_dvcs_server do
+ before(:create) do |project|
+ create(:project_feature_usage, :dvcs_server, project: project)
+ end
+ end
+
trait :archived do
archived { true }
end
@@ -383,6 +392,12 @@ FactoryBot.define do
end
end
+ factory :ewm_project, parent: :project do
+ has_external_issue_tracker { true }
+
+ ewm_service
+ end
+
factory :project_with_design, parent: :project do
after(:create) do |project|
issue = create(:issue, project: project)
diff --git a/spec/factories/resource_iteration_event.rb b/spec/factories/resource_iteration_event.rb
deleted file mode 100644
index 85e7320f7a7..00000000000
--- a/spec/factories/resource_iteration_event.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :resource_iteration_event do
- issue { merge_request.nil? ? create(:issue) : nil }
- merge_request { nil }
- iteration
- action { :add }
- user { issue&.author || merge_request&.author || create(:user) }
- end
-end
diff --git a/spec/factories/services.rb b/spec/factories/services.rb
index 7fbf6f16dc7..9056fd97f13 100644
--- a/spec/factories/services.rb
+++ b/spec/factories/services.rb
@@ -116,6 +116,12 @@ FactoryBot.define do
issue_tracker
end
+ factory :ewm_service do
+ project
+ active { true }
+ issue_tracker
+ end
+
trait :issue_tracker do
transient do
create_data { true }
diff --git a/spec/factories/snippet_repositories.rb b/spec/factories/snippet_repositories.rb
index 1f9e68514bb..c3a6bc3ae31 100644
--- a/spec/factories/snippet_repositories.rb
+++ b/spec/factories/snippet_repositories.rb
@@ -8,5 +8,13 @@ FactoryBot.define do
snippet_repository.shard_name = snippet_repository.snippet.repository_storage
snippet_repository.disk_path = snippet_repository.snippet.disk_path
end
+
+ trait(:checksummed) do
+ verification_checksum { 'abc' }
+ end
+
+ trait(:checksum_failure) do
+ verification_failure { 'Could not calculate the checksum' }
+ end
end
end
diff --git a/spec/factories/terraform/state.rb b/spec/factories/terraform/state.rb
index 46784581180..9decc89ef39 100644
--- a/spec/factories/terraform/state.rb
+++ b/spec/factories/terraform/state.rb
@@ -7,6 +7,7 @@ FactoryBot.define do
sequence(:name) { |n| "state-#{n}" }
trait :with_file do
+ versioning_enabled { false }
file { fixture_file_upload('spec/fixtures/terraform/terraform.tfstate', 'application/json') }
end
@@ -15,5 +16,24 @@ FactoryBot.define do
locked_at { Time.current }
locked_by_user { create(:user) }
end
+
+ trait(:checksummed) do
+ with_file
+ verification_checksum { 'abc' }
+ end
+
+ trait(:checksum_failure) do
+ with_file
+ verification_failure { 'Could not calculate the checksum' }
+ end
+
+ trait :with_version do
+ after(:create) do |state|
+ create(:terraform_state_version, :with_file, terraform_state: state)
+ end
+ end
+
+ # Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/235108
+ factory :legacy_terraform_state, parent: :terraform_state, traits: [:with_file]
end
end
diff --git a/spec/factories/terraform/state_version.rb b/spec/factories/terraform/state_version.rb
new file mode 100644
index 00000000000..d1bd78215e3
--- /dev/null
+++ b/spec/factories/terraform/state_version.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :terraform_state_version, class: 'Terraform::StateVersion' do
+ terraform_state factory: :terraform_state
+ created_by_user factory: :user
+
+ sequence(:version)
+ file { fixture_file_upload('spec/fixtures/terraform/terraform.tfstate', 'application/json') }
+ end
+end
diff --git a/spec/factories/usage_data.rb b/spec/factories/usage_data.rb
index d2b8fd94aca..5b20205a235 100644
--- a/spec/factories/usage_data.rb
+++ b/spec/factories/usage_data.rb
@@ -51,12 +51,11 @@ FactoryBot.define do
create(:protected_branch, name: 'main', project: projects[0])
# Incident Labeled Issues
- incident_label_attrs = IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES
- incident_label = create(:label, project: projects[0], **incident_label_attrs)
+ incident_label = create(:label, :incident, project: projects[0])
create(:labeled_issue, project: projects[0], labels: [incident_label])
incident_group = create(:group)
- incident_label_scoped_to_project = create(:label, project: projects[1], **incident_label_attrs)
- incident_label_scoped_to_group = create(:group_label, group: incident_group, **incident_label_attrs)
+ incident_label_scoped_to_project = create(:label, :incident, project: projects[1])
+ incident_label_scoped_to_group = create(:group_label, :incident, group: incident_group)
create(:labeled_issue, project: projects[1], labels: [incident_label_scoped_to_project])
create(:labeled_issue, project: projects[1], labels: [incident_label_scoped_to_group])
@@ -65,6 +64,10 @@ FactoryBot.define do
create(:alert_management_alert, issue: alert_bot_issues[0], project: projects[0])
create(:self_managed_prometheus_alert_event, related_issues: [issues[1]], project: projects[0])
+ # Kubernetes agents
+ create(:cluster_agent, project: projects[0])
+ create(:cluster_agent_token, agent: create(:cluster_agent, project: projects[1]) )
+
# Enabled clusters
gcp_cluster = create(:cluster_provider_gcp, :created).cluster
create(:cluster_provider_aws, :created)
@@ -94,6 +97,11 @@ FactoryBot.define do
create(:grafana_integration, project: projects[1], enabled: true)
create(:grafana_integration, project: projects[2], enabled: false)
+ create(:package, project: projects[0])
+ create(:package, project: projects[0])
+ create(:package, project: projects[1])
+ create(:package, created_at: 2.months.ago, project: projects[1])
+
ProjectFeature.first.update_attribute('repository_access_level', 0)
# Create fresh & a month (28-days SMAU) old data
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index 7e121b10632..1a8c5d7e40c 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -81,6 +81,14 @@ FactoryBot.define do
end
end
+ trait :two_factor_via_webauthn do
+ transient { registrations_count { 5 } }
+
+ after(:create) do |user, evaluator|
+ create_list(:webauthn_registration, evaluator.registrations_count, user: user)
+ end
+ end
+
trait :readme do
project_view { :readme }
end
@@ -128,6 +136,16 @@ FactoryBot.define do
end
end
+ factory :atlassian_user do
+ transient do
+ extern_uid { generate(:username) }
+ end
+
+ after(:create) do |user, evaluator|
+ create(:atlassian_identity, user: user, extern_uid: evaluator.extern_uid)
+ end
+ end
+
factory :admin, traits: [:admin]
end
end
diff --git a/spec/factories/webauthn_registrations.rb b/spec/factories/webauthn_registrations.rb
new file mode 100644
index 00000000000..ac803885244
--- /dev/null
+++ b/spec/factories/webauthn_registrations.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :webauthn_registration do
+ credential_xid { SecureRandom.base64(88) }
+ public_key { SecureRandom.base64(103) }
+ name { FFaker::BaconIpsum.characters(10) }
+ counter { 1 }
+ user
+ end
+end
diff --git a/spec/features/admin/admin_cohorts_spec.rb b/spec/features/admin/admin_cohorts_spec.rb
new file mode 100644
index 00000000000..f91446ed222
--- /dev/null
+++ b/spec/features/admin/admin_cohorts_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Cohorts page' do
+ before do
+ sign_in(create(:admin))
+ end
+
+ context 'with usage ping enabled' do
+ it 'shows users count per month' do
+ stub_application_setting(usage_ping_enabled: true)
+
+ create_list(:user, 2)
+
+ visit admin_cohorts_path
+
+ expect(page).to have_content("#{Time.now.strftime('%b %Y')} 3 0")
+ end
+ end
+
+ context 'with usage ping disabled' do
+ it 'shows empty state', :js do
+ stub_application_setting(usage_ping_enabled: false)
+
+ visit admin_cohorts_path
+
+ expect(page).to have_selector(".js-empty-state")
+ end
+ end
+end
diff --git a/spec/features/instance_statistics/dev_ops_score_spec.rb b/spec/features/admin/admin_dev_ops_report_spec.rb
index da87aedab5c..c201011cbea 100644
--- a/spec/features/instance_statistics/dev_ops_score_spec.rb
+++ b/spec/features/admin/admin_dev_ops_report_spec.rb
@@ -2,19 +2,19 @@
require 'spec_helper'
-RSpec.describe 'DevOps Score' do
+RSpec.describe 'DevOps Report page' do
before do
sign_in(create(:admin))
end
it 'has dismissable intro callout', :js do
- visit instance_statistics_dev_ops_score_index_path
+ visit admin_dev_ops_report_path
- expect(page).to have_content 'Introducing Your DevOps Score'
+ expect(page).to have_content 'Introducing Your DevOps Report'
find('.js-close-callout').click
- expect(page).not_to have_content 'Introducing Your DevOps Score'
+ expect(page).not_to have_content 'Introducing Your DevOps Report'
end
context 'when usage ping is disabled' do
@@ -22,16 +22,16 @@ RSpec.describe 'DevOps Score' do
stub_application_setting(usage_ping_enabled: false)
end
- it 'shows empty state' do
- visit instance_statistics_dev_ops_score_index_path
+ it 'shows empty state', :js do
+ visit admin_dev_ops_report_path
- expect(page).to have_content('Usage ping is not enabled')
+ expect(page).to have_selector(".js-empty-state")
end
it 'hides the intro callout' do
- visit instance_statistics_dev_ops_score_index_path
+ visit admin_dev_ops_report_path
- expect(page).not_to have_content 'Introducing Your DevOps Score'
+ expect(page).not_to have_content 'Introducing Your DevOps Report'
end
end
@@ -39,7 +39,7 @@ RSpec.describe 'DevOps Score' do
it 'shows empty state' do
stub_application_setting(usage_ping_enabled: true)
- visit instance_statistics_dev_ops_score_index_path
+ visit admin_dev_ops_report_path
expect(page).to have_content('Data is still calculating')
end
@@ -48,9 +48,9 @@ RSpec.describe 'DevOps Score' do
context 'when there is data to display' do
it 'shows numbers for each metric' do
stub_application_setting(usage_ping_enabled: true)
- create(:dev_ops_score_metric)
+ create(:dev_ops_report_metric)
- visit instance_statistics_dev_ops_score_index_path
+ visit admin_dev_ops_report_path
expect(page).to have_content(
'Issues created per active user 1.2 You 9.3 Lead 13.3%'
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index 9cd335ffb8c..f5c5a73c042 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe 'Admin Groups' do
let!(:current_user) { create(:admin) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
sign_in(current_user)
stub_application_setting(default_group_visibility: internal)
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index f5b05c76e90..38f0b813183 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -17,7 +17,10 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
end
context 'General page' do
+ let(:gitpod_feature_enabled) { true }
+
before do
+ stub_feature_flags(gitpod: gitpod_feature_enabled)
visit general_admin_application_settings_path
end
@@ -205,6 +208,32 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
expect(page).to have_content "Application settings saved successfully"
expect(current_settings.terminal_max_session_time).to eq(15)
end
+
+ context 'Configure Gitpod' do
+ context 'with feature disabled' do
+ let(:gitpod_feature_enabled) { false }
+
+ it 'do not show settings' do
+ expect(page).not_to have_selector('#js-gitpod-settings')
+ end
+ end
+
+ context 'with feature enabled' do
+ let(:gitpod_feature_enabled) { true }
+
+ it 'changes gitpod settings' do
+ page.within('#js-gitpod-settings') do
+ check 'Enable Gitpod integration'
+ fill_in 'Gitpod URL', with: 'https://gitpod.test/'
+ click_button 'Save changes'
+ end
+
+ expect(page).to have_content 'Application settings saved successfully'
+ expect(current_settings.gitpod_url).to eq('https://gitpod.test/')
+ expect(current_settings.gitpod_enabled).to be(true)
+ end
+ end
+ end
end
context 'Integrations page' do
@@ -232,7 +261,7 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
page.select 'All branches', from: 'Branches to be notified'
check_all_events
- click_on 'Save'
+ click_button 'Save changes'
expect(page).to have_content 'Application settings saved successfully'
@@ -285,6 +314,55 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
expect(current_settings.auto_devops_domain).to eq('domain.com')
expect(page).to have_content "Application settings saved successfully"
end
+
+ context 'Container Registry' do
+ context 'delete tags service execution timeout' do
+ let(:feature_flag_enabled) { true }
+ let(:client_support) { true }
+
+ before do
+ stub_container_registry_config(enabled: true)
+ stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
+ allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
+ end
+
+ RSpec.shared_examples 'not having service timeout settings' do
+ it 'lacks the timeout settings' do
+ visit ci_cd_admin_application_settings_path
+
+ expect(page).not_to have_content "Container Registry delete tags service execution timeout"
+ end
+ end
+
+ context 'with feature flag enabled' do
+ context 'with client supporting tag delete' do
+ it 'changes the timeout' do
+ visit ci_cd_admin_application_settings_path
+
+ page.within('.as-registry') do
+ fill_in 'application_setting_container_registry_delete_tags_service_timeout', with: 400
+ click_button 'Save changes'
+ end
+
+ expect(current_settings.container_registry_delete_tags_service_timeout).to eq(400)
+ expect(page).to have_content "Application settings saved successfully"
+ end
+ end
+
+ context 'with client not supporting tag delete' do
+ let(:client_support) { false }
+
+ it_behaves_like 'not having service timeout settings'
+ end
+ end
+
+ context 'with feature flag disabled' do
+ let(:feature_flag_enabled) { false }
+
+ it_behaves_like 'not having service timeout settings'
+ end
+ end
+ end
end
context 'Repository page' do
@@ -525,6 +603,7 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
end
def check_all_events
+ page.check('Active')
page.check('Push')
page.check('Issue')
page.check('Confidential Issue')
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index 6cd18f2755c..a37210d2acc 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -279,7 +279,8 @@ RSpec.describe "Admin::Users" do
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
- expect(page).to have_content(user.id)
+ expect(page).to have_content("ID: #{user.id}")
+ expect(page).to have_content("Namespace ID: #{user.namespace_id}")
expect(page).to have_button('Deactivate user')
expect(page).to have_button('Block user')
expect(page).to have_button('Delete user')
@@ -353,7 +354,7 @@ RSpec.describe "Admin::Users" do
it 'sees impersonation log out icon' do
subject
- icon = first('.fa.fa-user-secret')
+ icon = first('[data-testid="incognito-icon"]')
expect(icon).not_to be nil
end
@@ -536,7 +537,7 @@ RSpec.describe "Admin::Users" do
it 'allows group membership to be revoked', :js do
page.within(first('.group_member')) do
- accept_confirm { find('.btn-remove').click }
+ accept_confirm { find('.btn[data-testid="remove-user"]').click }
end
wait_for_requests
diff --git a/spec/features/admin/services/admin_activates_prometheus_spec.rb b/spec/features/admin/services/admin_activates_prometheus_spec.rb
index 35af9dd6c68..199eae59afc 100644
--- a/spec/features/admin/services/admin_activates_prometheus_spec.rb
+++ b/spec/features/admin/services/admin_activates_prometheus_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'Admin activates Prometheus', :js do
it 'activates service' do
check('Active')
fill_in('API URL', with: 'http://prometheus.example.com')
- click_button('Save')
+ click_button('Save changes')
expect(page).to have_content('Application settings saved successfully')
end
diff --git a/spec/features/admin/services/admin_visits_service_templates_spec.rb b/spec/features/admin/services/admin_visits_service_templates_spec.rb
index 8e02538ece0..a37e57304aa 100644
--- a/spec/features/admin/services/admin_visits_service_templates_spec.rb
+++ b/spec/features/admin/services/admin_visits_service_templates_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'Admin visits service templates' do
let(:admin) { create(:user, :admin) }
- let(:slack_service) { Service.templates.find { |s| s.type == 'SlackService' } }
+ let(:slack_service) { Service.for_template.find { |s| s.type == 'SlackService' } }
before do
sign_in(admin)
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index 8e2a9381aa0..e36378bd34e 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -6,11 +6,11 @@ RSpec.describe 'Issue Boards', :js do
include DragTo
include MobileHelpers
- let(:group) { create(:group, :nested) }
- let(:project) { create(:project, :public, namespace: group) }
- let(:board) { create(:board, project: project) }
- let(:user) { create(:user) }
- let!(:user2) { create(:user) }
+ let_it_be(:group) { create(:group, :nested) }
+ let_it_be(:project) { create(:project, :public, namespace: group) }
+ let_it_be(:board) { create(:board, project: project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user2) { create(:user) }
before do
project.add_maintainer(user)
@@ -62,30 +62,30 @@ RSpec.describe 'Issue Boards', :js do
end
context 'with lists' do
- let(:milestone) { create(:milestone, project: project) }
-
- let(:planning) { create(:label, project: project, name: 'Planning', description: 'Test') }
- let(:development) { create(:label, project: project, name: 'Development') }
- let(:testing) { create(:label, project: project, name: 'Testing') }
- let(:bug) { create(:label, project: project, name: 'Bug') }
- let!(:backlog) { create(:label, project: project, name: 'Backlog') }
- let!(:closed) { create(:label, project: project, name: 'Closed') }
- let!(:accepting) { create(:label, project: project, name: 'Accepting Merge Requests') }
- let!(:a_plus) { create(:label, project: project, name: 'A+') }
- let!(:list1) { create(:list, board: board, label: planning, position: 0) }
- let!(:list2) { create(:list, board: board, label: development, position: 1) }
-
- let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) }
- let!(:issue1) { create(:labeled_issue, project: project, title: 'aaa', description: '111', assignees: [user], labels: [planning], relative_position: 8) }
- let!(:issue2) { create(:labeled_issue, project: project, title: 'bbb', description: '222', author: user2, labels: [planning], relative_position: 7) }
- let!(:issue3) { create(:labeled_issue, project: project, title: 'ccc', description: '333', labels: [planning], relative_position: 6) }
- let!(:issue4) { create(:labeled_issue, project: project, title: 'ddd', description: '444', labels: [planning], relative_position: 5) }
- let!(:issue5) { create(:labeled_issue, project: project, title: 'eee', description: '555', labels: [planning], milestone: milestone, relative_position: 4) }
- let!(:issue6) { create(:labeled_issue, project: project, title: 'fff', description: '666', labels: [planning, development], relative_position: 3) }
- let!(:issue7) { create(:labeled_issue, project: project, title: 'ggg', description: '777', labels: [development], relative_position: 2) }
- let!(:issue8) { create(:closed_issue, project: project, title: 'hhh', description: '888') }
- let!(:issue9) { create(:labeled_issue, project: project, title: 'iii', description: '999', labels: [planning, testing, bug, accepting], relative_position: 1) }
- let!(:issue10) { create(:labeled_issue, project: project, title: 'issue +', description: 'A+ great issue', labels: [a_plus]) }
+ let_it_be(:milestone) { create(:milestone, project: project) }
+
+ let_it_be(:planning) { create(:label, project: project, name: 'Planning', description: 'Test') }
+ let_it_be(:development) { create(:label, project: project, name: 'Development') }
+ let_it_be(:testing) { create(:label, project: project, name: 'Testing') }
+ let_it_be(:bug) { create(:label, project: project, name: 'Bug') }
+ let_it_be(:backlog) { create(:label, project: project, name: 'Backlog') }
+ let_it_be(:closed) { create(:label, project: project, name: 'Closed') }
+ let_it_be(:accepting) { create(:label, project: project, name: 'Accepting Merge Requests') }
+ let_it_be(:a_plus) { create(:label, project: project, name: 'A+') }
+ let_it_be(:list1) { create(:list, board: board, label: planning, position: 0) }
+ let_it_be(:list2) { create(:list, board: board, label: development, position: 1) }
+
+ let_it_be(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) }
+ let_it_be(:issue1) { create(:labeled_issue, project: project, title: 'aaa', description: '111', assignees: [user], labels: [planning], relative_position: 8) }
+ let_it_be(:issue2) { create(:labeled_issue, project: project, title: 'bbb', description: '222', author: user2, labels: [planning], relative_position: 7) }
+ let_it_be(:issue3) { create(:labeled_issue, project: project, title: 'ccc', description: '333', labels: [planning], relative_position: 6) }
+ let_it_be(:issue4) { create(:labeled_issue, project: project, title: 'ddd', description: '444', labels: [planning], relative_position: 5) }
+ let_it_be(:issue5) { create(:labeled_issue, project: project, title: 'eee', description: '555', labels: [planning], milestone: milestone, relative_position: 4) }
+ let_it_be(:issue6) { create(:labeled_issue, project: project, title: 'fff', description: '666', labels: [planning, development], relative_position: 3) }
+ let_it_be(:issue7) { create(:labeled_issue, project: project, title: 'ggg', description: '777', labels: [development], relative_position: 2) }
+ let_it_be(:issue8) { create(:closed_issue, project: project, title: 'hhh', description: '888') }
+ let_it_be(:issue9) { create(:labeled_issue, project: project, title: 'iii', description: '999', labels: [planning, testing, bug, accepting], relative_position: 1) }
+ let_it_be(:issue10) { create(:labeled_issue, project: project, title: 'issue +', description: 'A+ great issue', labels: [a_plus]) }
before do
visit project_board_path(project, board)
@@ -636,7 +636,7 @@ RSpec.describe 'Issue Boards', :js do
end
context 'as guest user' do
- let(:user_guest) { create(:user) }
+ let_it_be(:user_guest) { create(:user) }
before do
project.add_guest(user_guest)
diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb
index efa1f8cfc0d..f434ea0c66f 100644
--- a/spec/features/boards/new_issue_spec.rb
+++ b/spec/features/boards/new_issue_spec.rb
@@ -130,24 +130,43 @@ RSpec.describe 'Issue Boards new issue', :js do
context 'group boards' do
let_it_be(:group) { create(:group, :public) }
- let_it_be(:project) { create(:project, namespace: group) }
+ let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:group_board) { create(:board, group: group) }
- let_it_be(:list) { create(:list, board: group_board, position: 0) }
+ let_it_be(:project_label) { create(:label, project: project, name: 'label') }
+ let_it_be(:list) { create(:list, board: group_board, label: project_label, position: 0) }
context 'for unauthorized users' do
- before do
- sign_in(user)
- visit group_board_path(group, group_board)
- wait_for_requests
- end
+ context 'when backlog does not exist' do
+ before do
+ sign_in(user)
+ visit group_board_path(group, group_board)
+ wait_for_requests
+ end
- it 'displays new issue button in open list' do
- expect(first('.board')).to have_selector('.issue-count-badge-add-button', count: 1)
+ it 'does not display new issue button in label list' do
+ page.within('.board.is-draggable') do
+ expect(page).not_to have_selector('.issue-count-badge-add-button')
+ end
+ end
end
- it 'does not display new issue button in label list' do
- page.within('.board.is-draggable') do
- expect(page).not_to have_selector('.issue-count-badge-add-button')
+ context 'when backlog list already exists' do
+ let!(:backlog_list) { create(:backlog_list, board: group_board) }
+
+ before do
+ sign_in(user)
+ visit group_board_path(group, group_board)
+ wait_for_requests
+ end
+
+ it 'displays new issue button in open list' do
+ expect(first('.board')).to have_selector('.issue-count-badge-add-button', count: 1)
+ end
+
+ it 'does not display new issue button in label list' do
+ page.within('.board.is-draggable') do
+ expect(page).not_to have_selector('.issue-count-badge-add-button')
+ end
end
end
end
diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb
index 65f2e5dfc0d..4b4cb444903 100644
--- a/spec/features/boards/sidebar_spec.rb
+++ b/spec/features/boards/sidebar_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe 'Issue Boards', :js do
let(:application_settings) { {} }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
before do
diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb
index 0294ebbe13f..b63079777cb 100644
--- a/spec/features/cycle_analytics_spec.rb
+++ b/spec/features/cycle_analytics_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe 'Value Stream Analytics', :js do
- let(:user) { create(:user) }
- let(:guest) { create(:user) }
- let(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
let(:milestone) { create(:milestone, project: project) }
let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
@@ -13,9 +13,11 @@ RSpec.describe 'Value Stream Analytics', :js do
context 'as an allowed user' do
context 'when project is new' do
- before do
+ before(:all) do
project.add_maintainer(user)
+ end
+ before do
sign_in(user)
visit project_cycle_analytics_path(project)
@@ -74,9 +76,6 @@ RSpec.describe 'Value Stream Analytics', :js do
click_stage('Staging')
expect_build_to_be_present
-
- click_stage('Total')
- expect_issue_to_be_present
end
context "when I change the time period observed" do
diff --git a/spec/features/dashboard/datetime_on_tooltips_spec.rb b/spec/features/dashboard/datetime_on_tooltips_spec.rb
index ed28ec6099d..a3eacd6147c 100644
--- a/spec/features/dashboard/datetime_on_tooltips_spec.rb
+++ b/spec/features/dashboard/datetime_on_tooltips_spec.rb
@@ -3,44 +3,53 @@
require 'spec_helper'
RSpec.describe 'Tooltips on .timeago dates', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, name: 'test', namespace: user.namespace) }
- let(:created_date) { Date.yesterday.to_time }
- let(:expected_format) { created_date.in_time_zone.strftime('%b %-d, %Y %l:%M%P') }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, name: 'test', namespace: user.namespace) }
+ let(:created_date) { 1.day.ago.beginning_of_minute - 1.hour }
+
+ before_all do
+ project.add_maintainer(user)
+ end
context 'on the activity tab' do
before do
- project.add_maintainer(user)
-
Event.create( project: project, author_id: user.id, action: :joined,
updated_at: created_date, created_at: created_date)
sign_in user
visit user_activity_path(user)
wait_for_requests
-
- page.find('.js-timeago').hover
end
it 'has the datetime formated correctly' do
- expect(page).to have_selector('.local-timeago', text: expected_format)
+ expect(page).to have_selector('.js-timeago', text: '1 day ago')
+
+ page.find('.js-timeago').hover
+
+ expect(datetime_in_tooltip).to eq(created_date)
end
end
context 'on the snippets tab' do
before do
- project.add_maintainer(user)
create(:snippet, author: user, updated_at: created_date, created_at: created_date)
sign_in user
visit user_snippets_path(user)
wait_for_requests
-
- page.find('.js-timeago.snippet-created-ago').hover
end
it 'has the datetime formated correctly' do
- expect(page).to have_selector('.local-timeago', text: expected_format)
+ expect(page).to have_selector('.js-timeago.snippet-created-ago', text: '1 day ago')
+
+ page.find('.js-timeago.snippet-created-ago').hover
+
+ expect(datetime_in_tooltip).to eq(created_date)
end
end
+
+ def datetime_in_tooltip
+ datetime_text = page.find('.local-timeago').text
+ DateTime.parse(datetime_text)
+ end
end
diff --git a/spec/features/dashboard/instance_statistics_spec.rb b/spec/features/dashboard/instance_statistics_spec.rb
deleted file mode 100644
index f85b8454113..00000000000
--- a/spec/features/dashboard/instance_statistics_spec.rb
+++ /dev/null
@@ -1,64 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Showing analytics' do
- before do
- sign_in user if user
- end
-
- # Using a path that is publicly accessible
- subject { visit explore_projects_path }
-
- context 'for unauthenticated users' do
- let(:user) { nil }
-
- it 'does not show the Analytics link' do
- subject
-
- expect(page).not_to have_link('Analytics')
- end
- end
-
- context 'for regular users' do
- let(:user) { create(:user) }
-
- context 'when instance statistics are publicly available' do
- before do
- stub_application_setting(instance_statistics_visibility_private: false)
- end
-
- it 'shows the analytics link' do
- subject
-
- expect(page).to have_link('Analytics')
- end
- end
-
- context 'when instance statistics are not publicly available' do
- before do
- stub_application_setting(instance_statistics_visibility_private: true)
- end
-
- it 'does not show the analytics link' do
- subject
-
- # Skipping this test on EE as there is an EE specifc spec for this functionality
- # ee/spec/features/dashboards/analytics_spec.rb
- skip if Gitlab.ee?
-
- expect(page).not_to have_link('Analytics')
- end
- end
- end
-
- context 'for admins' do
- let(:user) { create(:admin) }
-
- it 'shows the analytics link' do
- subject
-
- expect(page).to have_link('Analytics')
- end
- end
-end
diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb
index 6b8df8467e5..e705f2916da 100644
--- a/spec/features/expand_collapse_diffs_spec.rb
+++ b/spec/features/expand_collapse_diffs_spec.rb
@@ -7,11 +7,6 @@ RSpec.describe 'Expand and collapse diffs', :js do
let(:project) { create(:project, :repository) }
before do
- # Set the limits to those when these specs were written, to avoid having to
- # update the test repo every time we change them.
- allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes)
- allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes)
-
sign_in(create(:admin))
# Ensure that undiffable.md is in .gitattributes
diff --git a/spec/features/file_uploads/ci_artifact_spec.rb b/spec/features/file_uploads/ci_artifact_spec.rb
new file mode 100644
index 00000000000..4f3b6c90ad4
--- /dev/null
+++ b/spec/features/file_uploads/ci_artifact_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload ci artifact', :api, :js do
+ include_context 'file upload requests helpers'
+
+ let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
+ let_it_be(:runner) { create(:ci_runner, :project, projects: [project]) }
+ let_it_be(:job) { create(:ci_build, :running, user: user, project: project, pipeline: pipeline, runner_id: runner.id) }
+
+ let(:api_path) { "/jobs/#{job.id}/artifacts?token=#{job.token}" }
+ let(:url) { capybara_url(api(api_path)) }
+ let(:file) { fixture_file_upload('spec/fixtures/ci_build_artifacts.zip') }
+
+ subject do
+ HTTParty.post(url, body: { file: file })
+ end
+
+ RSpec.shared_examples 'for ci artifact' do
+ it { expect { subject }.to change { ::Ci::JobArtifact.count }.by(2) }
+
+ it { expect(subject.code).to eq(201) }
+ end
+
+ it_behaves_like 'handling file uploads', 'for ci artifact'
+end
diff --git a/spec/features/file_uploads/git_lfs_spec.rb b/spec/features/file_uploads/git_lfs_spec.rb
new file mode 100644
index 00000000000..b902d7ab702
--- /dev/null
+++ b/spec/features/file_uploads/git_lfs_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload a git lfs object', :js do
+ include_context 'file upload requests helpers'
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
+
+ let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') }
+ let(:oid) { Digest::SHA256.hexdigest(File.read(file.path)) }
+ let(:size) { file.size }
+ let(:url) { capybara_url("/#{project.namespace.path}/#{project.path}.git/gitlab-lfs/objects/#{oid}/#{size}") }
+ let(:headers) { { 'Content-Type' => 'application/octet-stream' } }
+
+ subject do
+ HTTParty.put(
+ url,
+ headers: headers,
+ basic_auth: { user: user.username, password: personal_access_token.token },
+ body: file.read
+ )
+ end
+
+ before do
+ stub_lfs_setting(enabled: true)
+ end
+
+ RSpec.shared_examples 'for a git lfs object' do
+ it { expect { subject }.to change { LfsObject.count }.by(1) }
+ it { expect(subject.code).to eq(200) }
+ end
+
+ it_behaves_like 'handling file uploads', 'for a git lfs object'
+end
diff --git a/spec/features/file_uploads/graphql_add_design_spec.rb b/spec/features/file_uploads/graphql_add_design_spec.rb
new file mode 100644
index 00000000000..f805ea86b4c
--- /dev/null
+++ b/spec/features/file_uploads/graphql_add_design_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload a design through graphQL', :js do
+ include_context 'file upload requests helpers'
+
+ let_it_be(:query) do
+ "
+ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
+ designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files}) {
+ clientMutationId,
+ errors
+ }
+ }
+ "
+ end
+
+ let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
+ let_it_be(:design) { create(:design) }
+ let_it_be(:operations) { { "operationName": "uploadDesign", "variables": { "files": [], "projectPath": design.project.full_path, "iid": design.issue.iid }, "query": query }.to_json }
+ let_it_be(:map) { { "1": ["variables.files.0"] }.to_json }
+
+ let(:url) { capybara_url("/api/graphql?private_token=#{personal_access_token.token}") }
+ let(:file) { fixture_file_upload('spec/fixtures/dk.png') }
+
+ subject do
+ HTTParty.post(
+ url,
+ body: {
+ operations: operations,
+ map: map,
+ "1": file
+ }
+ )
+ end
+
+ before do
+ stub_lfs_setting(enabled: true)
+ end
+
+ RSpec.shared_examples 'for a design upload through graphQL' do
+ it 'creates proper objects' do
+ expect { subject }
+ .to change { ::DesignManagement::Design.count }.by(1)
+ .and change { ::LfsObject.count }.by(1)
+ end
+
+ it { expect(subject.code).to eq(200) }
+ end
+
+ it_behaves_like 'handling file uploads', 'for a design upload through graphQL'
+end
diff --git a/spec/features/file_uploads/group_import_spec.rb b/spec/features/file_uploads/group_import_spec.rb
new file mode 100644
index 00000000000..0f9d05c3975
--- /dev/null
+++ b/spec/features/file_uploads/group_import_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload a group export archive', :api, :js do
+ include_context 'file upload requests helpers'
+
+ let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
+ let(:api_path) { '/groups/import' }
+ let(:url) { capybara_url(api(api_path, personal_access_token: personal_access_token)) }
+ let(:file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') }
+
+ subject do
+ HTTParty.post(
+ url,
+ body: {
+ path: 'test-import-group',
+ name: 'test-import-group',
+ file: file
+ }
+ )
+ end
+
+ RSpec.shared_examples 'for a group export archive' do
+ it { expect { subject }.to change { Group.count }.by(1) }
+
+ it { expect(subject.code).to eq(202) }
+ end
+
+ it_behaves_like 'handling file uploads', 'for a group export archive'
+end
diff --git a/spec/features/file_uploads/maven_package_spec.rb b/spec/features/file_uploads/maven_package_spec.rb
new file mode 100644
index 00000000000..c873a0e9a36
--- /dev/null
+++ b/spec/features/file_uploads/maven_package_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload a maven package', :api, :js do
+ include_context 'file upload requests helpers'
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
+
+ let(:api_path) { "/projects/#{project.id}/packages/maven/com/example/my-app/1.0/my-app-1.0-20180724.124855-1.jar" }
+ let(:url) { capybara_url(api(api_path, personal_access_token: personal_access_token)) }
+ let(:file) { fixture_file_upload('spec/fixtures/dk.png') }
+
+ subject { HTTParty.put(url, body: file.read) }
+
+ RSpec.shared_examples 'for a maven package' do
+ it 'creates package files' do
+ expect { subject }
+ .to change { Packages::Package.maven.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+ end
+
+ it { expect(subject.code).to eq(200) }
+ end
+
+ it_behaves_like 'handling file uploads', 'for a maven package'
+end
diff --git a/spec/features/file_uploads/nuget_package_spec.rb b/spec/features/file_uploads/nuget_package_spec.rb
new file mode 100644
index 00000000000..fb1e0a54744
--- /dev/null
+++ b/spec/features/file_uploads/nuget_package_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload a nuget package', :api, :js do
+ include_context 'file upload requests helpers'
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
+
+ let(:api_path) { "/projects/#{project.id}/packages/nuget/" }
+ let(:url) { capybara_url(api(api_path)) }
+ let(:file) { fixture_file_upload('spec/fixtures/dk.png') }
+
+ subject do
+ HTTParty.put(
+ url,
+ basic_auth: { user: user.username, password: personal_access_token.token },
+ body: { package: file }
+ )
+ end
+
+ RSpec.shared_examples 'for a nuget package' do
+ it 'creates package files' do
+ expect { subject }
+ .to change { Packages::Package.nuget.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+ end
+
+ it { expect(subject.code).to eq(201) }
+ end
+
+ it_behaves_like 'handling file uploads', 'for a nuget package'
+end
diff --git a/spec/features/file_uploads/project_import_spec.rb b/spec/features/file_uploads/project_import_spec.rb
new file mode 100644
index 00000000000..1bf16f46c63
--- /dev/null
+++ b/spec/features/file_uploads/project_import_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload a project export archive', :api, :js do
+ include_context 'file upload requests helpers'
+
+ let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
+ let(:api_path) { '/projects/import' }
+ let(:url) { capybara_url(api(api_path, personal_access_token: personal_access_token)) }
+ let(:file) { fixture_file_upload('spec/features/projects/import_export/test_project_export.tar.gz') }
+
+ subject do
+ HTTParty.post(
+ url,
+ body: {
+ path: 'test-import',
+ file: file
+ }
+ )
+ end
+
+ RSpec.shared_examples 'for a project export archive' do
+ it { expect { subject }.to change { Project.count }.by(1) }
+
+ it { expect(subject.code).to eq(201) }
+ end
+
+ it_behaves_like 'handling file uploads', 'for a project export archive'
+end
diff --git a/spec/features/file_uploads/user_avatar_spec.rb b/spec/features/file_uploads/user_avatar_spec.rb
new file mode 100644
index 00000000000..043115be61a
--- /dev/null
+++ b/spec/features/file_uploads/user_avatar_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload a user avatar', :js do
+ let_it_be(:user, reload: true) { create(:user) }
+ let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') }
+
+ before do
+ sign_in(user)
+ visit(profile_path)
+ attach_file('user_avatar-trigger', file.path, make_visible: true)
+ click_button 'Set new profile picture'
+ end
+
+ subject do
+ click_button 'Update profile settings'
+ end
+
+ RSpec.shared_examples 'for a user avatar' do
+ it 'uploads successfully' do
+ expect(user.avatar.file).to eq nil
+ subject
+
+ expect(page).to have_content 'Profile was successfully updated'
+ expect(user.reload.avatar.file).to be_present
+ expect(user.avatar).to be_instance_of AvatarUploader
+ expect(current_path).to eq(profile_path)
+ end
+ end
+
+ it_behaves_like 'handling file uploads', 'for a user avatar'
+end
diff --git a/spec/features/groups/board_sidebar_spec.rb b/spec/features/groups/board_sidebar_spec.rb
index 3bbeed10948..690d661ba2f 100644
--- a/spec/features/groups/board_sidebar_spec.rb
+++ b/spec/features/groups/board_sidebar_spec.rb
@@ -19,6 +19,8 @@ RSpec.describe 'Group Issue Boards', :js do
let(:card) { find('.board:nth-child(1)').first('.board-card') }
before do
+ # stubbing until sidebar work is done: https://gitlab.com/gitlab-org/gitlab/-/issues/230711
+ stub_feature_flags(graphql_board_lists: false)
sign_in(user)
visit group_board_path(group, board)
diff --git a/spec/features/groups/clusters/user_spec.rb b/spec/features/groups/clusters/user_spec.rb
index c6e5da92160..90253451d6b 100644
--- a/spec/features/groups/clusters/user_spec.rb
+++ b/spec/features/groups/clusters/user_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'User Cluster', :js do
visit group_clusters_path(group)
click_link 'Add Kubernetes cluster'
- click_link 'Add existing cluster'
+ click_link 'Connect existing cluster'
end
context 'when user filled form with valid parameters' do
diff --git a/spec/features/groups/import_export/import_file_spec.rb b/spec/features/groups/import_export/import_file_spec.rb
index ee4f2740f9f..f117b5d56e9 100644
--- a/spec/features/groups/import_export/import_file_spec.rb
+++ b/spec/features/groups/import_export/import_file_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe 'Import/Export - Group Import', :js do
fill_in :group_name, with: group_name
find('#import-group-tab').click
- expect(page).to have_content 'GitLab group export'
+ expect(page).to have_content 'Import a GitLab group export file'
attach_file(file) do
find('.js-filepicker-button').click
end
diff --git a/spec/features/groups/members/filter_members_spec.rb b/spec/features/groups/members/filter_members_spec.rb
index 643c8407578..d667690af29 100644
--- a/spec/features/groups/members/filter_members_spec.rb
+++ b/spec/features/groups/members/filter_members_spec.rb
@@ -10,6 +10,8 @@ RSpec.describe 'Groups > Members > Filter members' do
let(:nested_group) { create(:group, parent: group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
group.add_owner(user)
group.add_maintainer(user_with_2fa)
nested_group.add_maintainer(nested_group_user)
diff --git a/spec/features/groups/members/leave_group_spec.rb b/spec/features/groups/members/leave_group_spec.rb
index fecc90f20c7..9eb5cc15c5e 100644
--- a/spec/features/groups/members/leave_group_spec.rb
+++ b/spec/features/groups/members/leave_group_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe 'Groups > Members > Leave group' do
let(:group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
gitlab_sign_in(user)
end
diff --git a/spec/features/groups/members/list_members_spec.rb b/spec/features/groups/members/list_members_spec.rb
index 415c6927320..bcec2b50a24 100644
--- a/spec/features/groups/members/list_members_spec.rb
+++ b/spec/features/groups/members/list_members_spec.rb
@@ -12,6 +12,8 @@ RSpec.describe 'Groups > Members > List members' do
let(:nested_group) { create(:group, parent: group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
sign_in(user1)
end
diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb
index faf455e4ed9..e3bbbd4d73b 100644
--- a/spec/features/groups/members/manage_groups_spec.rb
+++ b/spec/features/groups/members/manage_groups_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
let(:shared_group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
shared_group.add_owner(user)
sign_in(user)
end
diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb
index 0267bea2f53..aedb7c170f8 100644
--- a/spec/features/groups/members/manage_members_spec.rb
+++ b/spec/features/groups/members/manage_members_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe 'Groups > Members > Manage members' do
let(:group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
sign_in(user1)
end
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 f80925186ed..d94cc85f411 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
@@ -11,6 +11,8 @@ RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js
let(:group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
group.add_owner(user1)
sign_in(user1)
end
diff --git a/spec/features/groups/members/master_manages_access_requests_spec.rb b/spec/features/groups/members/master_manages_access_requests_spec.rb
index 71c9b280ebe..44fd7380b79 100644
--- a/spec/features/groups/members/master_manages_access_requests_spec.rb
+++ b/spec/features/groups/members/master_manages_access_requests_spec.rb
@@ -3,6 +3,10 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Maintainer manages access requests' do
+ before do
+ stub_feature_flags(vue_group_members_list: false)
+ end
+
it_behaves_like 'Maintainer manages access requests' do
let(:has_tabs) { true }
let(:entity) { create(:group, :public) }
diff --git a/spec/features/groups/members/search_members_spec.rb b/spec/features/groups/members/search_members_spec.rb
index ad4f5c0b579..a95b59cece1 100644
--- a/spec/features/groups/members/search_members_spec.rb
+++ b/spec/features/groups/members/search_members_spec.rb
@@ -14,6 +14,8 @@ RSpec.describe 'Search group member' do
end
before do
+ stub_feature_flags(vue_group_members_list: false)
+
sign_in(user)
visit group_group_members_path(guest_group)
end
diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb
index cfc0e421aeb..d940550b18a 100644
--- a/spec/features/groups/members/sort_members_spec.rb
+++ b/spec/features/groups/members/sort_members_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe 'Groups > Members > Sort members' do
let(:group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
create(:group_member, :owner, user: owner, group: group, created_at: 5.days.ago)
create(:group_member, :developer, user: developer, group: group, created_at: 3.days.ago)
diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb
index 2217bd9d6b5..3ae9a2b7555 100644
--- a/spec/features/groups/milestone_spec.rb
+++ b/spec/features/groups/milestone_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe 'Group milestones' do
let_it_be(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
before do
diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb
index 6803b3a5785..60f1c404e78 100644
--- a/spec/features/groups/navbar_spec.rb
+++ b/spec/features/groups/navbar_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Group navbar' do
include NavbarStructureHelper
+ include WikiHelpers
include_context 'group navbar structure'
@@ -32,6 +33,7 @@ RSpec.describe 'Group navbar' do
nav_item: _('Merge Requests'),
nav_sub_items: []
},
+ (push_rules_nav_item if Gitlab.ee?),
{
nav_item: _('Kubernetes'),
nav_sub_items: []
@@ -47,9 +49,8 @@ RSpec.describe 'Group navbar' do
before do
insert_package_nav(_('Kubernetes'))
- stub_feature_flags(group_push_rules: false)
stub_feature_flags(group_iterations: false)
- stub_feature_flags(group_wiki: false)
+ stub_group_wikis(false)
group.add_maintainer(user)
sign_in(user)
end
diff --git a/spec/features/import/manifest_import_spec.rb b/spec/features/import/manifest_import_spec.rb
index 9c359e932d5..cfd0c7e210f 100644
--- a/spec/features/import/manifest_import_spec.rb
+++ b/spec/features/import/manifest_import_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe 'Import multiple repositories by uploading a manifest file', :js
attach_file('manifest', Rails.root.join('spec/fixtures/aosp_manifest.xml'))
click_on 'List available repositories'
- expect(page).to have_button('Import all repositories')
+ expect(page).to have_button('Import 660 repositories')
expect(page).to have_content('https://android-review.googlesource.com/platform/build/blueprint')
end
diff --git a/spec/features/instance_statistics/cohorts_spec.rb b/spec/features/instance_statistics/cohorts_spec.rb
deleted file mode 100644
index 1f112e1831c..00000000000
--- a/spec/features/instance_statistics/cohorts_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Cohorts page' do
- before do
- sign_in(create(:admin))
-
- stub_application_setting(usage_ping_enabled: true)
- end
-
- it 'See users count per month' do
- create_list(:user, 2)
-
- visit instance_statistics_cohorts_path
-
- expect(page).to have_content("#{Time.now.strftime('%b %Y')} 3 0")
- end
-end
diff --git a/spec/features/instance_statistics/instance_statistics_spec.rb b/spec/features/instance_statistics/instance_statistics_spec.rb
deleted file mode 100644
index 7695bf7874b..00000000000
--- a/spec/features/instance_statistics/instance_statistics_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Cohorts page', :js do
- before do
- sign_in(create(:admin))
- end
-
- it 'hides cohorts nav button when usage ping is disabled' do
- stub_application_setting(usage_ping_enabled: false)
-
- visit instance_statistics_root_path
-
- expect(find('.nav-sidebar')).not_to have_content('Cohorts')
- end
-
- it 'shows cohorts nav button when usage ping is enabled' do
- stub_application_setting(usage_ping_enabled: true)
-
- visit instance_statistics_root_path
-
- expect(find('.nav-sidebar')).to have_content('Cohorts')
- end
-end
diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb
index e7bcd7876ea..3954de56eea 100644
--- a/spec/features/invites_spec.rb
+++ b/spec/features/invites_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Invites', :aggregate_failures do
+RSpec.describe 'Group or Project invitations', :aggregate_failures do
let(:user) { create(:user, email: 'user@example.com') }
let(:owner) { create(:user, name: 'John Doe') }
let(:group) { create(:group, name: 'Owned') }
@@ -26,7 +26,6 @@ RSpec.describe 'Invites', :aggregate_failures do
fill_in 'new_user_name', with: new_user.name
fill_in 'new_user_username', with: new_user.username
fill_in 'new_user_email', with: new_user.email
- fill_in 'new_user_email_confirmation', with: new_user.email
fill_in 'new_user_password', with: new_user.password
click_button 'Register'
end
@@ -38,6 +37,11 @@ RSpec.describe 'Invites', :aggregate_failures do
click_button 'Sign in'
end
+ def fill_in_welcome_form
+ select 'Software Developer', from: 'user_role'
+ click_button 'Get started!'
+ end
+
context 'when signed out' do
before do
visit invite_path(group_invite.raw_invite_token)
@@ -94,6 +98,7 @@ RSpec.describe 'Invites', :aggregate_failures do
it 'signs up and redirects to the dashboard page with all the projects/groups invitations automatically accepted' do
fill_in_sign_up_form(new_user)
+ fill_in_welcome_form
expect(current_path).to eq(dashboard_projects_path)
expect(page).to have_content(project.full_name)
@@ -108,6 +113,7 @@ RSpec.describe 'Invites', :aggregate_failures do
it 'signs up and redirects to the invitation page' do
fill_in_sign_up_form(new_user)
+ fill_in_welcome_form
expect(current_path).to eq(invite_path(group_invite.raw_invite_token))
end
@@ -126,6 +132,7 @@ RSpec.describe 'Invites', :aggregate_failures do
fill_in_sign_up_form(new_user)
confirm_email(new_user)
fill_in_sign_in_form(new_user)
+ fill_in_welcome_form
expect(current_path).to eq(root_path)
expect(page).to have_content(project.full_name)
@@ -143,6 +150,7 @@ RSpec.describe 'Invites', :aggregate_failures do
it 'signs up and redirects to root page with all the project/groups invitation automatically accepted' do
fill_in_sign_up_form(new_user)
+ fill_in_welcome_form
confirm_email(new_user)
expect(current_path).to eq(root_path)
@@ -156,6 +164,7 @@ RSpec.describe 'Invites', :aggregate_failures do
it "doesn't accept invitations until the user confirms their email" do
fill_in_sign_up_form(new_user)
+ fill_in_welcome_form
sign_in(owner)
visit project_project_members_path(project)
@@ -175,6 +184,7 @@ RSpec.describe 'Invites', :aggregate_failures do
fill_in_sign_up_form(new_user)
confirm_email(new_user)
fill_in_sign_in_form(new_user)
+ fill_in_welcome_form
expect(current_path).to eq(invite_path(group_invite.raw_invite_token))
end
@@ -188,6 +198,7 @@ RSpec.describe 'Invites', :aggregate_failures do
it 'signs up and redirects to the invitation page' do
fill_in_sign_up_form(new_user)
+ fill_in_welcome_form
expect(current_path).to eq(invite_path(group_invite.raw_invite_token))
end
diff --git a/spec/features/issuables/close_reopen_report_toggle_spec.rb b/spec/features/issuables/close_reopen_report_toggle_spec.rb
index f442b25f593..5ea89a7984f 100644
--- a/spec/features/issuables/close_reopen_report_toggle_spec.rb
+++ b/spec/features/issuables/close_reopen_report_toggle_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Issuables Close/Reopen/Report toggle' do
+ include IssuablesHelper
+
let(:user) { create(:user) }
shared_examples 'an issuable close/reopen/report toggle' do
@@ -27,19 +29,11 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
expect(container).not_to have_selector('.reopen-item')
end
- it 'changes the button when an item is selected' do
- button = container.find('.issuable-close-button')
-
- container.find('.dropdown-toggle').click
- container.find('.report-item').click
-
- expect(container).not_to have_selector('.dropdown-menu')
- expect(button).to have_content('Report abuse')
-
+ it 'links to Report Abuse' do
container.find('.dropdown-toggle').click
- container.find('.close-item').click
+ container.find('.report-abuse-link').click
- expect(button).to have_content("Close #{human_model_name}")
+ expect(page).to have_content('Report abuse to admin')
end
end
diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb
index 7790d8f1c4c..b1ffaaa7c7e 100644
--- a/spec/features/issuables/issuable_list_spec.rb
+++ b/spec/features/issuables/issuable_list_spec.rb
@@ -51,8 +51,8 @@ RSpec.describe 'issuable list', :js do
it "counts merge requests closing issues icons for each issue" do
visit_issuable_list(:issue)
- expect(page).to have_selector('.icon-merge-request-unmerged', count: 1)
- expect(first('.icon-merge-request-unmerged').find(:xpath, '..')).to have_content(1)
+ expect(page).to have_selector('[data-testid="merge-requests"]', count: 1)
+ expect(first('[data-testid="merge-requests"]').find(:xpath, '..')).to have_content(1)
end
def visit_issuable_list(issuable_type)
diff --git a/spec/features/issuables/related_issues_spec.rb b/spec/features/issuables/related_issues_spec.rb
new file mode 100644
index 00000000000..837859bbe26
--- /dev/null
+++ b/spec/features/issuables/related_issues_spec.rb
@@ -0,0 +1,400 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Related issues', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project_empty_repo, :public) }
+ let(:project_b) { create(:project_empty_repo, :public) }
+ let(:project_unauthorized) { create(:project_empty_repo, :public) }
+ let(:issue_a) { create(:issue, project: project) }
+ let(:issue_b) { create(:issue, project: project) }
+ let(:issue_c) { create(:issue, project: project) }
+ let(:issue_d) { create(:issue, project: project) }
+ let(:issue_project_b_a) { create(:issue, project: project_b) }
+ let(:issue_project_unauthorized_a) { create(:issue, project: project_unauthorized) }
+
+ context 'widget visibility' do
+ context 'when not logged in' do
+ it 'does not show widget when internal project' do
+ project = create :project_empty_repo, :internal
+ issue = create :issue, project: project
+
+ visit project_issue_path(project, issue)
+
+ expect(page).not_to have_css('.related-issues-block')
+ end
+
+ it 'does not show widget when private project' do
+ project = create :project_empty_repo, :private
+ issue = create :issue, project: project
+
+ visit project_issue_path(project, issue)
+
+ expect(page).not_to have_css('.related-issues-block')
+ end
+
+ it 'shows widget when public project' do
+ project = create :project_empty_repo, :public
+ issue = create :issue, project: project
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).not_to have_selector('.js-issue-count-badge-add-button')
+ end
+ end
+
+ context 'when logged in but not a member' do
+ before do
+ gitlab_sign_in(user)
+ end
+
+ it 'shows widget when internal project' do
+ project = create :project_empty_repo, :internal
+ issue = create :issue, project: project
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).not_to have_selector('.js-issue-count-badge-add-button')
+ end
+
+ it 'does not show widget when private project' do
+ project = create :project_empty_repo, :private
+ issue = create :issue, project: project
+
+ visit project_issue_path(project, issue)
+
+ expect(page).not_to have_css('.related-issues-block')
+ end
+
+ it 'shows widget when public project' do
+ project = create :project_empty_repo, :public
+ issue = create :issue, project: project
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).not_to have_selector('.js-issue-count-badge-add-button')
+ end
+
+ it 'shows widget on their own public issue' do
+ project = create :project_empty_repo, :public
+ issue = create :issue, project: project, author: user
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).not_to have_selector('.js-issue-count-badge-add-button')
+ end
+ end
+
+ context 'when logged in and a guest' do
+ before do
+ gitlab_sign_in(user)
+ end
+
+ it 'shows widget when internal project' do
+ project = create :project_empty_repo, :internal
+ issue = create :issue, project: project
+ project.add_guest(user)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).not_to have_selector('.js-issue-count-badge-add-button')
+ end
+
+ it 'shows widget when private project' do
+ project = create :project_empty_repo, :private
+ issue = create :issue, project: project
+ project.add_guest(user)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).not_to have_selector('.js-issue-count-badge-add-button')
+ end
+
+ it 'shows widget when public project' do
+ project = create :project_empty_repo, :public
+ issue = create :issue, project: project
+ project.add_guest(user)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).not_to have_selector('.js-issue-count-badge-add-button')
+ end
+ end
+
+ context 'when logged in and a reporter' do
+ before do
+ gitlab_sign_in(user)
+ end
+
+ it 'shows widget when internal project' do
+ project = create :project_empty_repo, :internal
+ issue = create :issue, project: project
+ project.add_reporter(user)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).to have_selector('.js-issue-count-badge-add-button')
+ end
+
+ it 'shows widget when private project' do
+ project = create :project_empty_repo, :private
+ issue = create :issue, project: project
+ project.add_reporter(user)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).to have_selector('.js-issue-count-badge-add-button')
+ end
+
+ it 'shows widget when public project' do
+ project = create :project_empty_repo, :public
+ issue = create :issue, project: project
+ project.add_reporter(user)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).to have_selector('.js-issue-count-badge-add-button')
+ end
+
+ it 'shows widget on their own public issue' do
+ project = create :project_empty_repo, :public
+ issue = create :issue, project: project, author: user
+ project.add_reporter(user)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.related-issues-block')
+ expect(page).to have_selector('.js-issue-count-badge-add-button')
+ end
+ end
+ end
+
+ context 'when user has no permission to manage related issues' do
+ let!(:issue_link_b) { create :issue_link, source: issue_a, target: issue_b }
+ let!(:issue_link_c) { create :issue_link, source: issue_a, target: issue_c }
+
+ before do
+ project.add_guest(user)
+ gitlab_sign_in(user)
+ end
+
+ context 'visiting some issue someone else created' do
+ before do
+ visit project_issue_path(project, issue_a)
+ wait_for_requests
+ end
+
+ it 'shows related issues count' do
+ expect(find('.js-related-issues-header-issue-count')).to have_content('2')
+ end
+ end
+
+ context 'visiting issue_b which was targeted by issue_a' do
+ before do
+ visit project_issue_path(project, issue_b)
+ wait_for_requests
+ end
+
+ it 'shows related issues count' do
+ expect(find('.js-related-issues-header-issue-count')).to have_content('1')
+ end
+ end
+ end
+
+ context 'when user has permission to manage related issues' do
+ before do
+ project.add_maintainer(user)
+ project_b.add_maintainer(user)
+ gitlab_sign_in(user)
+ end
+
+ context 'without existing related issues' do
+ before do
+ visit project_issue_path(project, issue_a)
+ wait_for_requests
+ end
+
+ it 'shows related issues count' do
+ expect(find('.js-related-issues-header-issue-count')).to have_content('0')
+ end
+
+ it 'add related issue' do
+ find('.js-issue-count-badge-add-button').click
+ find('.js-add-issuable-form-input').set "#{issue_b.to_reference(project)} "
+ find('.js-add-issuable-form-add-button').click
+
+ wait_for_requests
+
+ items = all('.item-title a')
+
+ # Form gets hidden after submission
+ expect(page).not_to have_selector('.js-add-related-issues-form-area')
+ # Check if related issues are present
+ expect(items.count).to eq(1)
+ expect(items[0].text).to eq(issue_b.title)
+ expect(find('.js-related-issues-header-issue-count')).to have_content('1')
+ end
+
+ it 'add cross-project related issue' do
+ find('.js-issue-count-badge-add-button').click
+ find('.js-add-issuable-form-input').set "#{issue_project_b_a.to_reference(project)} "
+ find('.js-add-issuable-form-add-button').click
+
+ wait_for_requests
+
+ items = all('.item-title a')
+
+ expect(items.count).to eq(1)
+ expect(items[0].text).to eq(issue_project_b_a.title)
+ expect(find('.js-related-issues-header-issue-count')).to have_content('1')
+ end
+
+ it 'pressing enter should submit the form' do
+ find('.js-issue-count-badge-add-button').click
+ find('.js-add-issuable-form-input').set "#{issue_project_b_a.to_reference(project)} "
+ find('.js-add-issuable-form-input').native.send_key(:enter)
+
+ wait_for_requests
+
+ items = all('.item-title a')
+
+ expect(items.count).to eq(1)
+ expect(items[0].text).to eq(issue_project_b_a.title)
+ expect(find('.js-related-issues-header-issue-count')).to have_content('1')
+ end
+
+ it 'disallows duplicate entries' do
+ find('.js-issue-count-badge-add-button').click
+ find('.js-add-issuable-form-input').set 'duplicate duplicate duplicate'
+
+ items = all('.js-add-issuable-form-token-list-item')
+ expect(items.count).to eq(1)
+ expect(items[0].text).to eq('duplicate')
+
+ # Pending issues aren't counted towards the related issue count
+ expect(find('.js-related-issues-header-issue-count')).to have_content('0')
+ end
+
+ it 'allows us to remove pending issues' do
+ # Tests against https://gitlab.com/gitlab-org/gitlab/issues/11625
+ find('.js-issue-count-badge-add-button').click
+ find('.js-add-issuable-form-input').set 'issue1 issue2 issue3 '
+
+ items = all('.js-add-issuable-form-token-list-item')
+ expect(items.count).to eq(3)
+ expect(items[0].text).to eq('issue1')
+ expect(items[1].text).to eq('issue2')
+ expect(items[2].text).to eq('issue3')
+
+ # Remove pending issues left to right to make sure none get stuck
+ items[0].find('.js-issue-token-remove-button').click
+ items = all('.js-add-issuable-form-token-list-item')
+ expect(items.count).to eq(2)
+ expect(items[0].text).to eq('issue2')
+ expect(items[1].text).to eq('issue3')
+
+ items[0].find('.js-issue-token-remove-button').click
+ items = all('.js-add-issuable-form-token-list-item')
+ expect(items.count).to eq(1)
+ expect(items[0].text).to eq('issue3')
+
+ items[0].find('.js-issue-token-remove-button').click
+ items = all('.js-add-issuable-form-token-list-item')
+ expect(items.count).to eq(0)
+ end
+ end
+
+ context 'with existing related issues' do
+ let!(:issue_link_b) { create :issue_link, source: issue_a, target: issue_b }
+ let!(:issue_link_c) { create :issue_link, source: issue_a, target: issue_c }
+
+ before do
+ visit project_issue_path(project, issue_a)
+ wait_for_requests
+ end
+
+ it 'shows related issues count' do
+ expect(find('.js-related-issues-header-issue-count')).to have_content('2')
+ end
+
+ it 'shows related issues' do
+ items = all('.item-title a')
+
+ expect(items.count).to eq(2)
+ expect(items[0].text).to eq(issue_b.title)
+ expect(items[1].text).to eq(issue_c.title)
+ end
+
+ it 'allows us to remove a related issues' do
+ items_before = all('.item-title a')
+
+ expect(items_before.count).to eq(2)
+
+ first('.js-issue-item-remove-button').click
+
+ wait_for_requests
+
+ items_after = all('.item-title a')
+
+ expect(items_after.count).to eq(1)
+ end
+
+ it 'add related issue' do
+ find('.js-issue-count-badge-add-button').click
+ find('.js-add-issuable-form-input').set "##{issue_d.iid} "
+ find('.js-add-issuable-form-add-button').click
+
+ wait_for_requests
+
+ items = all('.item-title a')
+
+ expect(items.count).to eq(3)
+ expect(items[0].text).to eq(issue_b.title)
+ expect(items[1].text).to eq(issue_c.title)
+ expect(items[2].text).to eq(issue_d.title)
+ expect(find('.js-related-issues-header-issue-count')).to have_content('3')
+ end
+
+ it 'add invalid related issue' do
+ find('.js-issue-count-badge-add-button').click
+ find('.js-add-issuable-form-input').set "#9999999 "
+ find('.js-add-issuable-form-add-button').click
+
+ wait_for_requests
+
+ items = all('.item-title a')
+
+ expect(items.count).to eq(2)
+ expect(items[0].text).to eq(issue_b.title)
+ expect(items[1].text).to eq(issue_c.title)
+ expect(find('.js-related-issues-header-issue-count')).to have_content('2')
+ end
+
+ it 'add unauthorized related issue' do
+ find('.js-issue-count-badge-add-button').click
+ find('.js-add-issuable-form-input').set "#{issue_project_unauthorized_a.to_reference(project)} "
+ find('.js-add-issuable-form-add-button').click
+
+ wait_for_requests
+
+ items = all('.item-title a')
+
+ expect(items.count).to eq(2)
+ expect(items[0].text).to eq(issue_b.title)
+ expect(items[1].text).to eq(issue_c.title)
+ expect(find('.js-related-issues-header-issue-count')).to have_content('2')
+ end
+ end
+ end
+end
diff --git a/spec/features/issuables/sorting_list_spec.rb b/spec/features/issuables/sorting_list_spec.rb
index ff92fe369d4..d065e96885c 100644
--- a/spec/features/issuables/sorting_list_spec.rb
+++ b/spec/features/issuables/sorting_list_spec.rb
@@ -10,10 +10,6 @@ RSpec.describe 'Sort Issuable List' do
let(:first_updated_issuable) { issuables.order_updated_asc.first }
let(:last_updated_issuable) { issuables.order_updated_desc.first }
- before do
- stub_feature_flags(vue_issuables_list: false)
- end
-
context 'for merge requests' do
include MergeRequestHelpers
@@ -147,7 +143,7 @@ RSpec.describe 'Sort Issuable List' do
let(:issuable_type) { :issue }
it 'is "created date"' do
- visit_issues_with_state(project, 'open')
+ visit_issues_with_state(project, 'opened')
expect(find('.filter-dropdown-container')).to have_content('Created date')
expect(first_issue).to include(last_created_issuable.title)
@@ -179,11 +175,11 @@ RSpec.describe 'Sort Issuable List' do
end
end
- context 'when the sort in the URL is id_desc' do
+ context 'when the sort in the URL is created_date', :js do
let(:issuable_type) { :issue }
before do
- visit_issues(project, sort: 'id_desc')
+ visit_issues(project, sort: 'created_date')
end
it 'shows the sort order as created date' do
@@ -194,11 +190,11 @@ RSpec.describe 'Sort Issuable List' do
end
end
- context 'custom sorting' do
+ context 'custom sorting', :js do
let(:issuable_type) { :issue }
it 'supports sorting in asc and desc order' do
- visit_issues_with_state(project, 'open')
+ visit_issues_with_state(project, 'opened')
page.within('.filter-dropdown-container') do
click_button('Created date')
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index 3757985f99c..0165fba9ace 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -763,6 +763,29 @@ RSpec.describe 'GFM autocomplete', :js do
end
end
+ shared_examples 'autocomplete suggestions' do
+ it 'suggests objects correctly' do
+ page.within '.timeline-content-form' do
+ find('#note-body').native.send_keys(object.class.reference_prefix)
+ end
+
+ page.within '.tribute-container' do
+ expect(page).to have_content(object.title)
+
+ find('ul li').click
+ end
+
+ expect(find('.new-note #note-body').value).to include(expected_body)
+ end
+ end
+
+ context 'merge requests' do
+ let(:object) { create(:merge_request, source_project: project) }
+ let(:expected_body) { object.to_reference }
+
+ it_behaves_like 'autocomplete suggestions'
+ end
+
context 'when other notes are destroyed' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
diff --git a/spec/features/issues/group_label_sidebar_spec.rb b/spec/features/issues/group_label_sidebar_spec.rb
index e6a173f4589..8150f9c6faf 100644
--- a/spec/features/issues/group_label_sidebar_spec.rb
+++ b/spec/features/issues/group_label_sidebar_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Group label on issue' do
- it 'renders link to the project issues page' do
+ it 'renders link to the project issues page', :js do
group = create(:group)
project = create(:project, :public, namespace: group)
feature = create(:group_label, group: group, title: 'feature')
@@ -14,6 +14,6 @@ RSpec.describe 'Group label on issue' do
link = find('.issuable-show-labels a')
- expect(link[:href]).to eq(label_link)
+ expect(CGI.unescape(link[:href])).to include(CGI.unescape(label_link))
end
end
diff --git a/spec/features/issues/incident_issue_spec.rb b/spec/features/issues/incident_issue_spec.rb
new file mode 100644
index 00000000000..d004ee85dd8
--- /dev/null
+++ b/spec/features/issues/incident_issue_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Incident Detail', :js do
+ context 'when user displays the incident' do
+ it 'shows the incident tabs' do
+ project = create(:project, :public)
+ incident = create(:incident, project: project, description: 'hello')
+
+ visit project_issue_path(project, incident)
+ wait_for_requests
+
+ page.within('.issuable-details') do
+ incident_tabs = find('[data-testid="incident-tabs"]')
+
+ expect(find('h2')).to have_content(incident.title)
+ expect(incident_tabs).to have_content('Summary')
+ expect(incident_tabs).to have_content(incident.description)
+ end
+ end
+ end
+end
diff --git a/spec/features/issues/issue_detail_spec.rb b/spec/features/issues/issue_detail_spec.rb
index 9879703c8bf..1c8da227412 100644
--- a/spec/features/issues/issue_detail_spec.rb
+++ b/spec/features/issues/issue_detail_spec.rb
@@ -20,6 +20,19 @@ RSpec.describe 'Issue Detail', :js do
end
end
+ context 'when user displays the issue as an incident' do
+ let(:issue) { create(:incident, project: project, author: user) }
+
+ before do
+ visit project_issue_path(project, issue)
+ wait_for_requests
+ end
+
+ it 'does not show design management' do
+ expect(page).not_to have_selector('.js-design-management')
+ end
+ end
+
context 'when issue description has xss snippet' do
before do
issue.update!(description: '![xss" onload=alert(1);//](a)')
diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb
index ecda80f2483..38d11ee2560 100644
--- a/spec/features/issues/issue_sidebar_spec.rb
+++ b/spec/features/issues/issue_sidebar_spec.rb
@@ -168,7 +168,7 @@ RSpec.describe 'Issue Sidebar' do
it 'escapes XSS when viewing issue labels' do
page.within('.block.labels') do
- find('.edit-link').click
+ click_on 'Edit'
expect(page).to have_content '<script>alert("xss");</script>'
end
@@ -179,7 +179,7 @@ RSpec.describe 'Issue Sidebar' do
before do
issue.update(labels: [label])
page.within('.block.labels') do
- find('.edit-link').click
+ click_on 'Edit'
end
end
@@ -286,7 +286,7 @@ RSpec.describe 'Issue Sidebar' do
end
it 'does not have a option to edit labels' do
- expect(page).not_to have_selector('.block.labels .edit-link')
+ expect(page).not_to have_selector('.block.labels .js-sidebar-dropdown-toggle')
end
context 'interacting with collapsed sidebar', :js do
diff --git a/spec/features/issues/markdown_toolbar_spec.rb b/spec/features/issues/markdown_toolbar_spec.rb
index aab9d1026f9..6dc1cbfb2d7 100644
--- a/spec/features/issues/markdown_toolbar_spec.rb
+++ b/spec/features/issues/markdown_toolbar_spec.rb
@@ -36,6 +36,6 @@ RSpec.describe 'Issue markdown toolbar', :js do
all('.toolbar-btn')[1].click
- expect(find('#note-body')[:value]).to eq("test\n*underline*\n")
+ expect(find('#note-body')[:value]).to eq("test\n_underline_\n")
end
end
diff --git a/spec/features/issues/resource_label_events_spec.rb b/spec/features/issues/resource_label_events_spec.rb
index f2c978c525e..8faec85f3df 100644
--- a/spec/features/issues/resource_label_events_spec.rb
+++ b/spec/features/issues/resource_label_events_spec.rb
@@ -35,12 +35,12 @@ RSpec.describe 'List issue resource label events', :js do
context 'when user adds label to the issue' do
def toggle_labels(labels)
page.within '.labels' do
- click_link 'Edit'
+ click_on 'Edit'
wait_for_requests
labels.each { |label| click_link label }
- click_link 'Edit'
+ click_on 'Edit'
wait_for_requests
end
end
diff --git a/spec/features/issues/service_desk_spec.rb b/spec/features/issues/service_desk_spec.rb
index 2912ac33625..1512d539dec 100644
--- a/spec/features/issues/service_desk_spec.rb
+++ b/spec/features/issues/service_desk_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
let(:user) { create(:user) }
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)
@@ -27,53 +28,7 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
end
describe 'issues list' do
- context 'when service desk is misconfigured' do
- before do
- allow(Gitlab::ServiceDesk).to receive(:supported?).and_return(false)
- visit service_desk_project_issues_path(project)
- end
-
- it 'shows a message to say the configuration is incomplete' do
- expect(page).to have_css('.empty-state')
- expect(page).to have_text('Service Desk is enabled but not yet active')
- expect(page).to have_text('You must set up incoming email before it becomes active')
- expect(page).to have_link('More information', href: help_page_path('administration/incoming_email', anchor: 'set-it-up'))
- end
- end
-
- context 'when service desk has not been activated' do
- let(:project_without_service_desk) { create(:project, :private, service_desk_enabled: false) }
-
- describe 'service desk info content' do
- context 'when user has permissions to edit project settings' do
- before do
- project_without_service_desk.add_maintainer(user)
- visit service_desk_project_issues_path(project_without_service_desk)
- end
-
- it 'displays the large info box, documentation, and a button to configure' do
- aggregate_failures do
- expect(page).to have_css('.empty-state')
- expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
- expect(page).to have_link('Turn on Service Desk')
- end
- end
- end
-
- context 'when user does not have permission to edit project settings' do
- before do
- project_without_service_desk.add_guest(user)
- visit service_desk_project_issues_path(project_without_service_desk)
- end
-
- it 'does not show a button configure service desk' do
- expect(page).not_to have_link('Turn on Service Desk')
- end
- end
- end
- end
-
- context 'when service desk has been activated' do
+ context 'when service desk is supported' do
context 'when there are no issues' do
describe 'service desk info content' do
it 'displays the large info box, documentation, and the address' do
@@ -81,6 +36,7 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
aggregate_failures do
expect(page).to have_css('.empty-state')
+ expect(page).to have_text('Use Service Desk to connect with your users')
expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
expect(page).not_to have_link('Turn on Service Desk')
expect(page).to have_content(project.service_desk_address)
@@ -99,6 +55,7 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
it 'displays the large info box and the documentation link' do
aggregate_failures do
expect(page).to have_css('.empty-state')
+ expect(page).to have_text('Use Service Desk to connect with your users')
expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
expect(page).not_to have_link('Turn on Service Desk')
expect(page).not_to have_content(project.service_desk_address)
@@ -152,6 +109,55 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
find('.input-token .filtered-search').native.send_key(:backspace)
expect(page).to have_selector('.js-visual-token', count: 1)
end
+
+ it 'support bot author token has been properly added' do
+ within('.filtered-search-token') do
+ expect(page).to have_selector('.name', count: 1, visible: false)
+ expect(page).to have_selector('.operator', count: 1, visible: false)
+ expect(page).to have_selector('.value-container', count: 1, visible: false)
+ end
+ end
+ end
+ end
+ end
+
+ context 'when service desk is not supported' do
+ let(:project_without_service_desk) { create(:project, :private, service_desk_enabled: false) }
+
+ before do
+ allow(Gitlab::ServiceDesk).to receive(:supported?).and_return(false)
+ visit service_desk_project_issues_path(project)
+ end
+
+ describe 'service desk info content' do
+ context 'when user has permissions to edit project settings' do
+ before do
+ project_without_service_desk.add_maintainer(user)
+ visit service_desk_project_issues_path(project_without_service_desk)
+ end
+
+ it 'informs user to setup incoming email to turn on support for Service Desk' do
+ aggregate_failures do
+ expect(page).to have_css('.empty-state')
+ expect(page).to have_text('Service Desk is not supported')
+ expect(page).to have_text('In order to enable Service Desk for your instance, you must first set up incoming email.')
+ expect(page).to have_link('More information', href: help_page_path('administration/incoming_email', anchor: 'set-it-up'))
+ end
+ end
+ end
+
+ context 'when user does not have permission to edit project settings' do
+ before do
+ project_without_service_desk.add_developer(user)
+ visit service_desk_project_issues_path(project_without_service_desk)
+ end
+
+ it 'informs user to contact an administrator to enable service desk' do
+ expect(page).to have_css('.empty-state')
+ # NOTE: here, "enabled" is not used in the sense of "ServiceDesk::Enabled?"
+ expect(page).to have_text('Service Desk is not enabled')
+ expect(page).to have_text('For help setting up the Service Desk for your instance, please contact an administrator.')
+ end
end
end
end
diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb
index a2c868d0256..668b4265948 100644
--- a/spec/features/issues/user_creates_issue_spec.rb
+++ b/spec/features/issues/user_creates_issue_spec.rb
@@ -61,6 +61,10 @@ RSpec.describe "User creates issue" do
.and have_content(project.name)
expect(page).to have_selector('strong', text: 'Description')
end
+
+ it 'does not render the issue type dropdown' do
+ expect(page).not_to have_selector('.s-issuable-type-filter-dropdown-wrap')
+ end
end
context "when signed in as developer", :js do
@@ -188,6 +192,50 @@ RSpec.describe "User creates issue" do
end
end
+ context 'form create handles issue creation by default' do
+ let(:project) { create(:project) }
+
+ before do
+ visit new_project_issue_path(project)
+ 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')
+ end
+
+ it 'does not hide the milestone select' do
+ expect(page).to have_selector('.qa-issuable-milestone-dropdown')
+ end
+ end
+
+ context 'form create handles incident creation' do
+ let(:project) { create(:project) }
+
+ before do
+ visit new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident' } })
+ 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')
+ end
+
+ it 'hides the epic select' do
+ expect(page).not_to have_selector('.epic-dropdown-container')
+ end
+
+ it 'hides the milestone select' do
+ expect(page).not_to have_selector('.qa-issuable-milestone-dropdown')
+ end
+
+ it 'hides the weight input' do
+ expect(page).not_to have_selector('.qa-issuable-weight-input')
+ end
+
+ it 'shows the incident help text' do
+ expect(page).to have_text('A modified issue to guide the resolution of incidents.')
+ end
+ end
+
context 'suggestions', :js do
it 'displays list of related issues' do
issue = create(:issue, project: project)
diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb
index 39bf535c715..88b8e9624e2 100644
--- a/spec/features/issues/user_edits_issue_spec.rb
+++ b/spec/features/issues/user_edits_issue_spec.rb
@@ -4,13 +4,17 @@ require "spec_helper"
RSpec.describe "Issues > User edits issue", :js do
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) }
let_it_be(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
+ let_it_be(:issue_with_milestones) { create(:issue, project: project_with_milestones, author: user, assignees: [user]) }
let_it_be(:label) { create(:label, project: project) }
let_it_be(:milestone) { create(:milestone, project: project) }
+ let_it_be(:milestones) { create_list(:milestone, 25, project: project_with_milestones) }
before do
project.add_developer(user)
+ project_with_milestones.add_developer(user)
sign_in(user)
end
@@ -91,11 +95,12 @@ RSpec.describe "Issues > User edits issue", :js do
describe 'update labels' do
it 'will not send ajax request when no data is changed' do
page.within '.labels' do
- click_link 'Edit'
+ click_on 'Edit'
- find('.dropdown-menu-close', match: :first).click
+ find('.dropdown-title button').click
expect(page).not_to have_selector('.block-loading')
+ expect(page).not_to have_selector('.gl-spinner')
end
end
end
@@ -148,9 +153,7 @@ RSpec.describe "Issues > User edits issue", :js do
visit project_issue_path(project, issue2)
page.within '.assignee' do
- page.within '.value .author' do
- expect(page).to have_content user.name
- end
+ expect(page).to have_content user.name
click_link 'Edit'
click_link user.name
@@ -218,6 +221,23 @@ RSpec.describe "Issues > User edits issue", :js do
end
end
end
+
+ it 'allows user to search milestone' do
+ visit project_issue_path(project_with_milestones, issue_with_milestones)
+
+ page.within('.milestone') do
+ click_link 'Edit'
+ wait_for_requests
+ # We need to enclose search string in quotes for exact match as all the milestone titles
+ # within tests are prefixed with `My title`.
+ find('.dropdown-input-field', visible: true).send_keys "\"#{milestones[0].title}\""
+ wait_for_requests
+
+ page.within '.dropdown-content' do
+ expect(page).to have_content milestones[0].title
+ end
+ end
+ end
end
context 'by unauthorized user' do
diff --git a/spec/features/issues/user_interacts_with_awards_spec.rb b/spec/features/issues/user_interacts_with_awards_spec.rb
index 35d9db68d32..7db72f2cd05 100644
--- a/spec/features/issues/user_interacts_with_awards_spec.rb
+++ b/spec/features/issues/user_interacts_with_awards_spec.rb
@@ -184,6 +184,31 @@ RSpec.describe 'User interacts with awards' do
wait_for_requests
end
+ context 'when the issue is locked' do
+ before do
+ create(:award_emoji, awardable: issue, name: '100')
+ issue.update!(discussion_locked: true)
+
+ visit project_issue_path(project, issue)
+ wait_for_requests
+ end
+
+ it 'hides the add award button' do
+ page.within('.awards') do
+ expect(page).not_to have_css('.js-add-award')
+ end
+ end
+
+ it 'does not allow toggling existing emoji' do
+ page.within('.awards') do
+ find('gl-emoji[data-name="100"]').click
+ end
+ wait_for_requests
+
+ expect(issue.reload.award_emoji.size).to eq(1)
+ end
+ end
+
it 'adds award to issue' do
first('.js-emoji-btn').click
diff --git a/spec/features/issues/user_views_issue_spec.rb b/spec/features/issues/user_views_issue_spec.rb
index 2b610bab9f0..3f18764aa58 100644
--- a/spec/features/issues/user_views_issue_spec.rb
+++ b/spec/features/issues/user_views_issue_spec.rb
@@ -3,12 +3,16 @@
require "spec_helper"
RSpec.describe "User views issue" do
- let(:project) { create(:project_empty_repo, :public) }
- let(:user) { create(:user) }
- let(:issue) { create(:issue, project: project, description: "# Description header", author: user) }
+ let_it_be(:project) { create(:project_empty_repo, :public) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:issue) { create(:issue, project: project, description: "# Description header", author: user) }
+ let_it_be(:note) { create(:note, noteable: issue, project: project, author: user) }
- before do
+ before_all do
project.add_developer(user)
+ end
+
+ before do
sign_in(user)
visit(project_issue_path(project, issue))
@@ -37,30 +41,30 @@ RSpec.describe "User views issue" do
context 'when showing status of the author of the issue' do
it_behaves_like 'showing user status' do
- let(:user_with_status) { issue.author }
+ let(:user_with_status) { user }
end
end
context 'when showing status of a user who commented on an issue', :js do
- let!(:note) { create(:note, noteable: issue, project: project, author: user_with_status) }
-
it_behaves_like 'showing user status' do
- let(:user_with_status) { create(:user) }
+ let(:user_with_status) { user }
end
end
context 'when status message has an emoji', :js do
- let(:message) { 'My status with an emoji' }
- let(:message_emoji) { 'basketball' }
-
- let!(:note) { create(:note, noteable: issue, project: project, author: user) }
- let!(:status) { create(:user_status, user: user, emoji: 'smirk', message: "#{message} :#{message_emoji}:") }
+ let_it_be(:message) { 'My status with an emoji' }
+ let_it_be(:message_emoji) { 'basketball' }
+ let_it_be(:status) { create(:user_status, user: user, emoji: 'smirk', message: "#{message} :#{message_emoji}:") }
it 'correctly renders the emoji' do
+ wait_for_requests
+
tooltip_span = page.first(".user-status-emoji[title^='#{message}']")
tooltip_span.hover
+ wait_for_requests
+
tooltip = page.find('.tooltip .tooltip-inner')
page.within(tooltip) do
diff --git a/spec/features/jira_connect/subscriptions_spec.rb b/spec/features/jira_connect/subscriptions_spec.rb
new file mode 100644
index 00000000000..9be6b7c67ee
--- /dev/null
+++ b/spec/features/jira_connect/subscriptions_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Subscriptions Content Security Policy' do
+ let(:installation) { create(:jira_connect_installation) }
+ let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test') }
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
+
+ subject { response_headers['Content-Security-Policy'] }
+
+ context 'when there is no global config' do
+ before do
+ expect_next_instance_of(JiraConnect::SubscriptionsController) do |controller|
+ expect(controller).to receive(:current_content_security_policy)
+ .and_return(ActionDispatch::ContentSecurityPolicy.new)
+ end
+ end
+
+ it 'does not add CSP directives' do
+ visit jira_connect_subscriptions_path(jwt: jwt)
+
+ is_expected.to be_blank
+ end
+ end
+
+ context 'when a global CSP config exists' do
+ before do
+ csp = ActionDispatch::ContentSecurityPolicy.new do |p|
+ p.script_src :self, 'https://some-cdn.test'
+ p.style_src :self, 'https://some-cdn.test'
+ end
+
+ expect_next_instance_of(JiraConnect::SubscriptionsController) do |controller|
+ expect(controller).to receive(:current_content_security_policy).and_return(csp)
+ end
+ end
+
+ it 'appends to CSP directives' do
+ visit jira_connect_subscriptions_path(jwt: jwt)
+
+ is_expected.to include("frame-ancestors 'self' https://*.atlassian.net")
+ is_expected.to include("script-src 'self' https://some-cdn.test https://connect-cdn.atl-paas.net https://unpkg.com/jquery@3.3.1/")
+ is_expected.to include("style-src 'self' https://some-cdn.test 'unsafe-inline' https://unpkg.com/@atlaskit/")
+ end
+ end
+end
diff --git a/spec/features/jira_oauth_provider_authorize_spec.rb b/spec/features/jira_oauth_provider_authorize_spec.rb
new file mode 100644
index 00000000000..daecae56101
--- /dev/null
+++ b/spec/features/jira_oauth_provider_authorize_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'JIRA OAuth Provider' do
+ describe 'JIRA DVCS OAuth Authorization' do
+ let(:application) { create(:oauth_application, redirect_uri: oauth_jira_callback_url, scopes: 'read_user') }
+
+ before do
+ sign_in(user)
+
+ visit oauth_jira_authorize_path(client_id: application.uid,
+ redirect_uri: oauth_jira_callback_url,
+ response_type: 'code',
+ state: 'my_state',
+ scope: 'read_user')
+ end
+
+ it_behaves_like 'Secure OAuth Authorizations'
+ end
+end
diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb
index 1545cb36e9b..eed9a6d1043 100644
--- a/spec/features/labels_hierarchy_spec.rb
+++ b/spec/features/labels_hierarchy_spec.rb
@@ -42,12 +42,12 @@ RSpec.describe 'Labels Hierarchy', :js do
it 'does not find child group labels on dropdown' do
page.within('.block.labels') do
- find('.edit-link').click
- end
+ click_on 'Edit'
- wait_for_requests
+ wait_for_requests
- expect(page).not_to have_selector('.badge', text: child_group_label.title)
+ expect(page).not_to have_text(child_group_label.title)
+ end
end
end
@@ -296,6 +296,7 @@ RSpec.describe 'Labels Hierarchy', :js do
let(:board) { create(:board, group: parent) }
before do
+ stub_feature_flags(graphql_board_lists: false)
parent.add_developer(user)
visit group_board_path(parent, board)
find('.js-new-board-list').click
diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb
index 57362ed2d54..fbf4e531db1 100644
--- a/spec/features/markdown/copy_as_gfm_spec.rb
+++ b/spec/features/markdown/copy_as_gfm_spec.rb
@@ -30,13 +30,11 @@ RSpec.describe 'Copy as GFM', :js do
it 'works', :aggregate_failures do
verify(
'nesting',
-
'> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**'
)
verify(
'a real world example from the gitlab-ce README',
-
<<~GFM
# GitLab
@@ -103,19 +101,16 @@ RSpec.describe 'Copy as GFM', :js do
verify(
'InlineDiffFilter',
-
'{-Deleted text-}',
'{+Added text+}'
)
verify(
'TaskListFilter',
-
<<~GFM,
* [ ] Unchecked task
* [x] Checked task
GFM
-
<<~GFM
1. [ ] Unchecked ordered task
1. [x] Checked ordered task
@@ -124,7 +119,6 @@ RSpec.describe 'Copy as GFM', :js do
verify(
'ReferenceFilter',
-
# issue reference
@feat.issue.to_reference,
# full issue reference
@@ -141,13 +135,11 @@ RSpec.describe 'Copy as GFM', :js do
verify(
'AutolinkFilter',
-
'https://example.com'
)
verify(
'TableOfContentsFilter',
-
<<~GFM,
[[_TOC_]]
@@ -155,64 +147,53 @@ RSpec.describe 'Copy as GFM', :js do
## Heading 2
GFM
-
pipeline: :wiki,
wiki: @project.wiki
)
verify(
'EmojiFilter',
-
':thumbsup:'
)
verify(
'ImageLinkFilter',
-
'![Image](https://example.com/image.png)'
)
verify_media_with_partial_path(
'[test.txt](/uploads/a123/image.txt)',
-
project_media_uri(@project, '/uploads/a123/image.txt')
)
verify_media_with_partial_path(
'![Image](/uploads/a123/image.png)',
-
project_media_uri(@project, '/uploads/a123/image.png')
)
verify(
'VideoLinkFilter',
-
'![Video](https://example.com/video.mp4)'
)
verify_media_with_partial_path(
'![Video](/uploads/a123/video.mp4)',
-
project_media_uri(@project, '/uploads/a123/video.mp4')
)
verify(
'AudioLinkFilter',
-
'![Audio](https://example.com/audio.wav)'
)
verify_media_with_partial_path(
'![Audio](/uploads/a123/audio.wav)',
-
project_media_uri(@project, '/uploads/a123/audio.wav')
)
verify(
'MathFilter: math as converted from GFM to HTML',
-
'$`c = \pm\sqrt{a^2 + b^2}`$',
-
# math block
<<~GFM
```math
@@ -334,7 +315,6 @@ RSpec.describe 'Copy as GFM', :js do
verify(
'MermaidFilter: mermaid as converted from GFM to HTML',
-
<<~GFM
```mermaid
graph TD;
@@ -429,7 +409,6 @@ RSpec.describe 'Copy as GFM', :js do
verify(
'SuggestionFilter: suggestion as converted from GFM to HTML',
-
<<~GFM
```suggestion
New
@@ -491,7 +470,6 @@ RSpec.describe 'Copy as GFM', :js do
verify(
'SanitizationFilter',
-
<<~GFM
<sub>sub</sub>
@@ -527,13 +505,11 @@ RSpec.describe 'Copy as GFM', :js do
verify(
'SanitizationFilter',
-
<<~GFM,
```
Plain text
```
GFM
-
<<~GFM,
```ruby
def foo
@@ -541,7 +517,6 @@ RSpec.describe 'Copy as GFM', :js do
end
```
GFM
-
<<~GFM
Foo
@@ -553,27 +528,19 @@ RSpec.describe 'Copy as GFM', :js do
verify(
'MarkdownFilter',
-
"Line with two spaces at the end \nto insert a linebreak",
-
'`code`',
'`` code with ` ticks ``',
-
'> Quote',
-
# multiline quote
<<~GFM,
> Multiline Quote
>
> With multiple paragraphs
GFM
-
'![Image](https://example.com/image.png)',
-
'# Heading with no anchor link',
-
'[Link](https://example.com)',
-
<<~GFM,
* List item
* List item 2
@@ -598,7 +565,6 @@ RSpec.describe 'Copy as GFM', :js do
> Blockquote
GFM
-
<<~GFM,
1. Ordered list item
1. Ordered list item 2
@@ -623,22 +589,16 @@ RSpec.describe 'Copy as GFM', :js do
---
GFM
-
'# Heading',
'## Heading',
'### Heading',
'#### Heading',
'##### Heading',
'###### Heading',
-
'**Bold**',
-
'*Italics*',
-
'~~Strikethrough~~',
-
'---',
-
# table
<<~GFM,
| Centered | Right | Left |
@@ -696,9 +656,7 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as inline code' do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line .no',
-
'`RuntimeError`',
-
target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'
)
end
@@ -708,9 +666,7 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as inline code' do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]',
-
'`raise RuntimeError, "System commands must be given as an array of strings"`',
-
target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'
)
end
@@ -720,14 +676,12 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as a code block' do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
-
<<~GFM,
```ruby
raise RuntimeError, "System commands must be given as an array of strings"
end
```
GFM
-
target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'
)
end
@@ -755,7 +709,6 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as a code block' do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
-
<<~GFM,
```ruby
unless cmd.is_a?(Array)
@@ -763,7 +716,6 @@ RSpec.describe 'Copy as GFM', :js do
end
```
GFM
-
target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].left-side'
)
end
@@ -773,7 +725,6 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as a code block' do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
-
<<~GFM,
```ruby
unless cmd.is_a?(Array)
@@ -781,7 +732,6 @@ RSpec.describe 'Copy as GFM', :js do
end
```
GFM
-
target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].right-side'
)
end
@@ -799,7 +749,6 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as inline code' do
verify(
'.line[id="LC9"] .no',
-
'`RuntimeError`'
)
end
@@ -809,7 +758,6 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as inline code' do
verify(
'.line[id="LC9"]',
-
'`raise RuntimeError, "System commands must be given as an array of strings"`'
)
end
@@ -819,7 +767,6 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as a code block' do
verify(
'.line[id="LC9"], .line[id="LC10"]',
-
<<~GFM
```ruby
raise RuntimeError, "System commands must be given as an array of strings"
@@ -841,7 +788,6 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as inline code' do
verify(
'.line[id="LC27"] .nl',
-
'`"bio"`'
)
end
@@ -851,7 +797,6 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as inline code' do
verify(
'.line[id="LC27"]',
-
'`"bio": null,`'
)
end
@@ -861,7 +806,6 @@ RSpec.describe 'Copy as GFM', :js do
it 'copies as a code block with the correct language' do
verify(
'.line[id="LC27"], .line[id="LC28"]',
-
<<~GFM
```json
"bio": null,
diff --git a/spec/features/markdown/keyboard_shortcuts_spec.rb b/spec/features/markdown/keyboard_shortcuts_spec.rb
new file mode 100644
index 00000000000..81b1928658c
--- /dev/null
+++ b/spec/features/markdown/keyboard_shortcuts_spec.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Markdown keyboard shortcuts', :js do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
+ let(:is_mac) { page.evaluate_script('navigator.platform').include?('Mac') }
+ let(:modifier_key) { is_mac ? :command : :control }
+ let(:other_modifier_key) { is_mac ? :control : :command }
+
+ before do
+ project.add_developer(user)
+
+ gitlab_sign_in(user)
+
+ visit path_to_visit
+
+ wait_for_requests
+ end
+
+ shared_examples 'keyboard shortcuts' do
+ it 'bolds text when <modifier>+B is pressed' do
+ type_and_select('bold')
+
+ markdown_field.send_keys([modifier_key, 'b'])
+
+ expect(markdown_field.value).to eq('**bold**')
+ end
+
+ it 'italicizes text when <modifier>+I is pressed' do
+ type_and_select('italic')
+
+ markdown_field.send_keys([modifier_key, 'i'])
+
+ expect(markdown_field.value).to eq('_italic_')
+ end
+
+ it 'links text when <modifier>+K is pressed' do
+ type_and_select('link')
+
+ markdown_field.send_keys([modifier_key, 'k'])
+
+ expect(markdown_field.value).to eq('[link](url)')
+
+ # Type some more text to ensure the cursor
+ # and selection are set correctly
+ markdown_field.send_keys('https://example.com')
+
+ expect(markdown_field.value).to eq('[link](https://example.com)')
+ end
+
+ it 'does not affect non-markdown fields on the same page' do
+ non_markdown_field.send_keys('some text')
+
+ non_markdown_field.send_keys([modifier_key, 'b'])
+
+ expect(focused_element).to eq(non_markdown_field.native)
+ expect(markdown_field.value).to eq('')
+ end
+ end
+
+ shared_examples 'no side effects' do
+ it 'does not bold text when <other modifier>+B is pressed' do
+ type_and_select('bold')
+
+ markdown_field.send_keys([@other_modifier_key, 'b'])
+
+ expect(markdown_field.value).not_to eq('**bold**')
+ end
+
+ it 'does not italicize text when <other modifier>+I is pressed' do
+ type_and_select('italic')
+
+ markdown_field.send_keys([@other_modifier_key, 'i'])
+
+ expect(markdown_field.value).not_to eq('_italic_')
+ end
+
+ it 'does not link text when <other modifier>+K is pressed' do
+ type_and_select('link')
+
+ markdown_field.send_keys([@other_modifier_key, 'k'])
+
+ expect(markdown_field.value).not_to eq('[link](url)')
+ end
+ end
+
+ context 'Vue.js markdown editor' do
+ let(:path_to_visit) { new_project_release_path(project) }
+ let(:markdown_field) { find_field('Release notes') }
+ let(:non_markdown_field) { find_field('Release title') }
+
+ it_behaves_like 'keyboard shortcuts'
+ it_behaves_like 'no side effects'
+ end
+
+ context 'Haml markdown editor' do
+ let(:path_to_visit) { new_project_issue_path(project) }
+ let(:markdown_field) { find_field('Description') }
+ let(:non_markdown_field) { find_field('Title') }
+
+ it_behaves_like 'keyboard shortcuts'
+ it_behaves_like 'no side effects'
+ end
+
+ def type_and_select(text)
+ markdown_field.send_keys(text)
+
+ text.length.times do
+ markdown_field.send_keys([:shift, :arrow_left])
+ end
+ end
+
+ def focused_element
+ page.driver.browser.switch_to.active_element
+ end
+end
diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb
index d9d3f566bce..151ef76e884 100644
--- a/spec/features/markdown/markdown_spec.rb
+++ b/spec/features/markdown/markdown_spec.rb
@@ -247,6 +247,7 @@ RSpec.describe 'GitLab Markdown', :aggregate_failures do
expect(doc).to reference_commits
expect(doc).to reference_labels
expect(doc).to reference_milestones
+ expect(doc).to reference_alerts
end
aggregate_failures 'TaskListFilter' do
diff --git a/spec/features/markdown/mermaid_spec.rb b/spec/features/markdown/mermaid_spec.rb
index 256dfdc26e9..bdb549326fa 100644
--- a/spec/features/markdown/mermaid_spec.rb
+++ b/spec/features/markdown/mermaid_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe 'Mermaid rendering', :js do
expect(page.html.scan(expected).count).to be(4)
end
- it 'renders only 2 Mermaid blocks and ', :js do
+ it 'renders only 2 Mermaid blocks and ', :js, quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/234081' } do
description = <<~MERMAID
```mermaid
graph LR
diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb
index 60671213d75..40f6482c948 100644
--- a/spec/features/merge_request/batch_comments_spec.rb
+++ b/spec/features/merge_request/batch_comments_spec.rb
@@ -20,8 +20,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
context 'Feature is enabled' do
before do
- stub_feature_flags(diffs_batch_load: false)
-
visit_diffs
end
diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb
index be12f774c29..0e65cb358da 100644
--- a/spec/features/merge_request/maintainer_edits_fork_spec.rb
+++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb
@@ -20,8 +20,6 @@ RSpec.describe 'a maintainer edits files on a source-branch of an MR from a fork
end
before do
- stub_feature_flags(single_mr_diff_view: false)
-
target_project.add_maintainer(user)
sign_in(user)
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 d7c9c8bddb1..3d18aef9327 100644
--- a/spec/features/merge_request/user_accepts_merge_request_spec.rb
+++ b/spec/features/merge_request/user_accepts_merge_request_spec.rb
@@ -12,12 +12,38 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
sign_in(user)
end
- it 'presents merged merge request content' do
- visit(merge_request_path(merge_request))
+ context 'presents merged merge request content' do
+ it 'when merge method is set to merge commit' do
+ visit(merge_request_path(merge_request))
+
+ click_button('Merge')
+
+ expect(page).to have_content("The changes were merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
+ end
+
+ context 'when merge method is set to fast-forward merge' do
+ let(:project) { create(:project, :public, :repository, merge_requests_ff_only_enabled: true) }
+
+ it 'accepts a merge request with rebase and merge' do
+ merge_request = create(:merge_request, :rebased, source_project: project)
+
+ visit(merge_request_path(merge_request))
- click_button('Merge')
+ click_button('Merge')
- expect(page).to have_content("The changes were merged into #{merge_request.target_branch} with #{merge_request.short_merge_commit_sha}")
+ expect(page).to have_content("The changes were merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
+ end
+
+ it 'accepts a merge request with squash and merge' do
+ merge_request = create(:merge_request, :rebased, source_project: project, squash: true)
+
+ visit(merge_request_path(merge_request))
+
+ click_button('Merge')
+
+ expect(page).to have_content("The changes were merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
+ end
+ end
end
context 'with removing the source branch' do
diff --git a/spec/features/merge_request/user_assigns_themselves_spec.rb b/spec/features/merge_request/user_assigns_themselves_spec.rb
index b6cd97dcc5a..04d401683bf 100644
--- a/spec/features/merge_request/user_assigns_themselves_spec.rb
+++ b/spec/features/merge_request/user_assigns_themselves_spec.rb
@@ -21,6 +21,15 @@ RSpec.describe 'Merge request > User assigns themselves' do
expect(page).to have_content '2 issues have been assigned to you'
end
+ it 'updates updated_by', :js do
+ expect do
+ click_button 'assign yourself'
+
+ expect(find('.assignee')).to have_content(user.name)
+ wait_for_all_requests
+ end.to change { merge_request.reload.updated_at }
+ end
+
it 'returns user to the merge request', :js do
click_link 'Assign yourself to these issues'
diff --git a/spec/features/merge_request/user_edits_mr_spec.rb b/spec/features/merge_request/user_edits_mr_spec.rb
index 2c949ed84f4..397ca70f4a1 100644
--- a/spec/features/merge_request/user_edits_mr_spec.rb
+++ b/spec/features/merge_request/user_edits_mr_spec.rb
@@ -20,4 +20,32 @@ RSpec.describe 'Merge request > User edits MR' do
include_context 'merge request edit context'
it_behaves_like 'an editable merge request'
end
+
+ context 'when merge_request_reviewers is turned on' do
+ before do
+ stub_feature_flags(merge_request_reviewers: true)
+ end
+
+ context 'non-fork merge request' do
+ include_context 'merge request edit context'
+ it_behaves_like 'an editable merge request with reviewers'
+ 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 reviewers'
+ end
+ end
+
+ context 'when merge_request_reviewers is turned off' do
+ before do
+ stub_feature_flags(merge_request_reviewers: false)
+ end
+
+ it 'does not render reviewers dropdown' do
+ expect(page).not_to have_selector('.js-reviewer-search')
+ end
+ end
end
diff --git a/spec/features/merge_request/user_expands_diff_spec.rb b/spec/features/merge_request/user_expands_diff_spec.rb
index d3867a91846..0e39cce13a1 100644
--- a/spec/features/merge_request/user_expands_diff_spec.rb
+++ b/spec/features/merge_request/user_expands_diff_spec.rb
@@ -7,23 +7,18 @@ RSpec.describe 'User expands diff', :js do
let(:merge_request) { create(:merge_request, source_branch: 'expand-collapse-files', source_project: project, target_project: project) }
before do
- stub_feature_flags(diffs_batch_load: false)
-
- allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes)
- allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes)
-
visit(diffs_project_merge_request_path(project, merge_request))
wait_for_requests
end
it 'allows user to expand diff' do
- page.within find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9"]') do
- click_link 'Click to expand it.'
+ page.within find('[id="19763941ab80e8c09871c0a425f0560d9053bcb3"]') do
+ find('[data-testid="expandButton"]').click
wait_for_requests
- expect(page).not_to have_content('Click to expand it.')
+ expect(page).not_to have_content('Expand File')
expect(page).to have_selector('.code')
end
end
diff --git a/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb b/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb
index 1a7baff2fb1..782a7e3bfb6 100644
--- a/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb
+++ b/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb
@@ -10,9 +10,6 @@ RSpec.describe 'Batch diffs', :js do
let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'master', target_branch: 'empty-branch') }
before do
- stub_feature_flags(single_mr_diff_view: project)
- stub_feature_flags(diffs_batch_load: true)
-
sign_in(project.owner)
visit diffs_project_merge_request_path(merge_request.project, merge_request)
diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb
index 4c079b98c90..489582521b5 100644
--- a/spec/features/merge_request/user_posts_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -82,7 +82,7 @@ RSpec.describe 'Merge request > User posts notes', :js do
it 'shows a reply button' do
reply_button = find('.js-reply-button', match: :first)
- expect(reply_button).to have_selector('.ic-comment')
+ expect(reply_button).to have_selector('[data-testid="comment-icon"]')
end
it 'shows reply placeholder when clicking reply button' do
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 f2adfd21e49..cd06886169d 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
@@ -15,10 +15,6 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
diff_refs: merge_request.diff_refs)
end
- before do
- stub_feature_flags(diffs_batch_load: false)
- end
-
context 'no threads' do
before do
project.add_maintainer(user)
diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
index 7fad805866b..d15d5b3bc73 100644
--- a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
@@ -20,7 +20,6 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do
let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) }
before do
- stub_feature_flags(diffs_batch_load: false)
project.add_maintainer(user)
sign_in user
diff --git a/spec/features/merge_request/user_sees_diff_spec.rb b/spec/features/merge_request/user_sees_diff_spec.rb
index d067fc0ada4..7a3a14e61e3 100644
--- a/spec/features/merge_request/user_sees_diff_spec.rb
+++ b/spec/features/merge_request/user_sees_diff_spec.rb
@@ -9,10 +9,6 @@ RSpec.describe 'Merge request > User sees diff', :js do
let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
- before do
- stub_feature_flags(diffs_batch_load: false)
- end
-
context 'when linking to note' do
describe 'with unresolved note' do
let(:note) { create :diff_note_on_merge_request, project: project, noteable: merge_request }
@@ -72,7 +68,7 @@ RSpec.describe 'Merge request > User sees diff', :js do
end
context 'as user who needs to fork' do
- it 'shows fork/cancel confirmation', :sidekiq_might_not_need_inline, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/196749' do
+ it 'shows fork/cancel confirmation', :sidekiq_might_not_need_inline do
sign_in(user)
visit diffs_project_merge_request_path(project, merge_request)
@@ -97,7 +93,7 @@ RSpec.describe 'Merge request > User sees diff', :js do
let c = 3;
let d = 3;
}
- CONTENT
+ CONTENT
new_file_content =
<<~CONTENT
@@ -107,7 +103,7 @@ RSpec.describe 'Merge request > User sees diff', :js do
let c = 3;
let x = 3;
}
- CONTENT
+ CONTENT
file_name = 'xss_file.rs'
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 c7d26dfc814..93fea44707c 100644
--- a/spec/features/merge_request/user_sees_merge_widget_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb
@@ -127,8 +127,10 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
end
- context 'when merge request is in the blocked pipeline state' do
+ context 'when merge request is in the blocked pipeline state and pipeline must succeed' do
before do
+ project.update_attribute(:only_allow_merge_if_pipeline_succeeds, true)
+
create(
:ci_pipeline,
project: project,
diff --git a/spec/features/merge_request/user_sees_pipelines_spec.rb b/spec/features/merge_request/user_sees_pipelines_spec.rb
index 5d41e49c478..8e15ba6cf8d 100644
--- a/spec/features/merge_request/user_sees_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_pipelines_spec.rb
@@ -123,6 +123,10 @@ RSpec.describe 'Merge request > User sees pipelines', :js do
context 'when actor is a developer in parent project' do
let(:actor) { developer_in_parent }
+ before do
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false)
+ end
+
it 'creates a pipeline in the parent project when user proceeds with the warning' do
visit project_merge_request_path(parent_project, merge_request)
diff --git a/spec/features/merge_request/user_sees_versions_spec.rb b/spec/features/merge_request/user_sees_versions_spec.rb
index 60e054ddbee..fb616ceae9d 100644
--- a/spec/features/merge_request/user_sees_versions_spec.rb
+++ b/spec/features/merge_request/user_sees_versions_spec.rb
@@ -17,8 +17,6 @@ RSpec.describe 'Merge request > User sees versions', :js do
let!(:params) { {} }
before do
- stub_feature_flags(diffs_batch_load: false)
-
project.add_maintainer(user)
sign_in(user)
visit diffs_project_merge_request_path(project, merge_request, params)
diff --git a/spec/features/merge_request/user_views_auto_expanding_diff_spec.rb b/spec/features/merge_request/user_views_auto_expanding_diff_spec.rb
index 20a5910e66d..1748f66c934 100644
--- a/spec/features/merge_request/user_views_auto_expanding_diff_spec.rb
+++ b/spec/features/merge_request/user_views_auto_expanding_diff_spec.rb
@@ -11,9 +11,6 @@ RSpec.describe 'User views diffs file-by-file', :js do
let(:user) { create(:user, view_diffs_file_by_file: true) }
before do
- allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes)
- allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes)
-
project.add_developer(user)
sign_in(user)
@@ -27,9 +24,10 @@ RSpec.describe 'User views diffs file-by-file', :js do
page.within('#diffs') do
expect(page).not_to have_content('This diff is collapsed')
- click_button 'Next'
+ find('.page-link.next-page-item').click
expect(page).not_to have_content('This diff is collapsed')
+ expect(page).to have_selector('.diff-file .file-title', text: 'large_diff_renamed.md')
end
end
end
diff --git a/spec/features/merge_request/user_views_diffs_file_by_file_spec.rb b/spec/features/merge_request/user_views_diffs_file_by_file_spec.rb
index abb313cb529..bb4bf0864c9 100644
--- a/spec/features/merge_request/user_views_diffs_file_by_file_spec.rb
+++ b/spec/features/merge_request/user_views_diffs_file_by_file_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe 'User views diffs file-by-file', :js do
expect(page).to have_selector('.file-holder', count: 1)
expect(page).to have_selector('.diff-file .file-title', text: '.DS_Store')
- click_button 'Next'
+ find('.page-link.next-page-item').click
expect(page).to have_selector('.file-holder', count: 1)
expect(page).to have_selector('.diff-file .file-title', text: '.gitignore')
diff --git a/spec/features/merge_request/user_views_diffs_spec.rb b/spec/features/merge_request/user_views_diffs_spec.rb
index 537c0473fa4..928755bf5de 100644
--- a/spec/features/merge_request/user_views_diffs_spec.rb
+++ b/spec/features/merge_request/user_views_diffs_spec.rb
@@ -11,7 +11,6 @@ RSpec.describe 'User views diffs', :js do
let(:view) { 'inline' }
before do
- stub_feature_flags(diffs_batch_load: false)
visit(diffs_project_merge_request_path(project, merge_request, view: view))
wait_for_requests
@@ -62,7 +61,7 @@ RSpec.describe 'User views diffs', :js do
end
it 'expands all diffs' do
- first('#a5cc2925ca8258af241be7e5b0381edf30266302 .js-file-title').click
+ first('.js-file-title').click
expect(page).to have_button('Expand all')
diff --git a/spec/features/merge_requests/user_views_diffs_commit_spec.rb b/spec/features/merge_requests/user_views_diffs_commit_spec.rb
index fcaabf9b0e7..cf92603972e 100644
--- a/spec/features/merge_requests/user_views_diffs_commit_spec.rb
+++ b/spec/features/merge_requests/user_views_diffs_commit_spec.rb
@@ -10,7 +10,6 @@ RSpec.describe 'User views diff by commit', :js do
let(:project) { create(:project, :public, :repository) }
before do
- stub_feature_flags(diffs_batch_load: false)
visit(diffs_project_merge_request_path(project, merge_request, commit_id: merge_request.diff_head_sha))
end
diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb
index 620c2f60ba3..e8caa2159a4 100644
--- a/spec/features/profiles/account_spec.rb
+++ b/spec/features/profiles/account_spec.rb
@@ -9,6 +9,39 @@ RSpec.describe 'Profile > Account', :js do
sign_in(user)
end
+ describe 'Social sign-in' do
+ context 'when an identity does not exist' do
+ before do
+ allow(Devise).to receive_messages(omniauth_configs: { google_oauth2: {} })
+ end
+
+ it 'allows the user to connect' do
+ visit profile_account_path
+
+ expect(page).to have_link('Connect Google', href: '/users/auth/google_oauth2')
+ end
+ end
+
+ context 'when an identity already exists' do
+ before do
+ allow(Devise).to receive_messages(omniauth_configs: { twitter: {}, saml: {} })
+
+ create(:identity, user: user, provider: :twitter)
+ create(:identity, user: user, provider: :saml)
+
+ visit profile_account_path
+ end
+
+ it 'allows the user to disconnect when there is an existing identity' do
+ expect(page).to have_link('Disconnect Twitter', href: '/profile/account/unlink?provider=twitter')
+ end
+
+ it 'shows active for a provider that is not allowed to unlink' do
+ expect(page).to have_content('Saml Active')
+ end
+ end
+ end
+
describe 'Change username' do
let(:new_username) { 'bar' }
let(:new_user_path) { "/#{new_username}" }
diff --git a/spec/features/projects/artifacts/raw_spec.rb b/spec/features/projects/artifacts/raw_spec.rb
index d72a35fddf8..d580262d48b 100644
--- a/spec/features/projects/artifacts/raw_spec.rb
+++ b/spec/features/projects/artifacts/raw_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Raw artifact', :js do
+RSpec.describe 'Raw artifact' do
let(:project) { create(:project, :public) }
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb
index 3032d115a00..7c564d76f70 100644
--- a/spec/features/projects/blobs/blob_show_spec.rb
+++ b/spec/features/projects/blobs/blob_show_spec.rb
@@ -564,35 +564,76 @@ RSpec.describe 'File blob', :js do
file_path: '.gitlab/dashboards/custom-dashboard.yml',
file_content: file_content
).execute
-
- visit_blob('.gitlab/dashboards/custom-dashboard.yml')
end
- context 'valid dashboard file' do
- let(:file_content) { File.read(Rails.root.join('config/prometheus/common_metrics.yml')) }
+ context 'with metrics_dashboard_exhaustive_validations feature flag off' do
+ before do
+ stub_feature_flags(metrics_dashboard_exhaustive_validations: false)
+ visit_blob('.gitlab/dashboards/custom-dashboard.yml')
+ end
- it 'displays an auxiliary viewer' do
- aggregate_failures do
- # shows that dashboard yaml is valid
- expect(page).to have_content('Metrics Dashboard YAML definition is valid.')
+ context 'valid dashboard file' do
+ let(:file_content) { File.read(Rails.root.join('config/prometheus/common_metrics.yml')) }
+
+ it 'displays an auxiliary viewer' do
+ aggregate_failures do
+ # shows that dashboard yaml is valid
+ expect(page).to have_content('Metrics Dashboard YAML definition is valid.')
+
+ # shows a learn more link
+ expect(page).to have_link('Learn more')
+ end
+ end
+ end
+
+ context 'invalid dashboard file' do
+ let(:file_content) { "dashboard: 'invalid'" }
- # shows a learn more link
- expect(page).to have_link('Learn more')
+ it 'displays an auxiliary viewer' do
+ aggregate_failures do
+ # shows that dashboard yaml is invalid
+ expect(page).to have_content('Metrics Dashboard YAML definition is invalid:')
+ expect(page).to have_content("panel_groups: should be an array of panel_groups objects")
+
+ # shows a learn more link
+ expect(page).to have_link('Learn more')
+ end
end
end
end
- context 'invalid dashboard file' do
- let(:file_content) { "dashboard: 'invalid'" }
+ context 'with metrics_dashboard_exhaustive_validations feature flag on' do
+ before do
+ stub_feature_flags(metrics_dashboard_exhaustive_validations: true)
+ visit_blob('.gitlab/dashboards/custom-dashboard.yml')
+ end
- it 'displays an auxiliary viewer' do
- aggregate_failures do
- # shows that dashboard yaml is invalid
- expect(page).to have_content('Metrics Dashboard YAML definition is invalid:')
- expect(page).to have_content("panel_groups: should be an array of panel_groups objects")
+ context 'valid dashboard file' do
+ let(:file_content) { File.read(Rails.root.join('config/prometheus/common_metrics.yml')) }
- # shows a learn more link
- expect(page).to have_link('Learn more')
+ it 'displays an auxiliary viewer' do
+ aggregate_failures do
+ # shows that dashboard yaml is valid
+ expect(page).to have_content('Metrics Dashboard YAML definition is valid.')
+
+ # shows a learn more link
+ expect(page).to have_link('Learn more')
+ end
+ end
+ end
+
+ context 'invalid dashboard file' do
+ let(:file_content) { "dashboard: 'invalid'" }
+
+ it 'displays an auxiliary viewer' do
+ aggregate_failures do
+ # shows that dashboard yaml is invalid
+ expect(page).to have_content('Metrics Dashboard YAML definition is invalid:')
+ expect(page).to have_content("root is missing required keys: panel_groups")
+
+ # shows a learn more link
+ expect(page).to have_link('Learn more')
+ end
end
end
end
diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb
index dbd1cebd515..0e2444c5434 100644
--- a/spec/features/projects/branches_spec.rb
+++ b/spec/features/projects/branches_spec.rb
@@ -97,7 +97,7 @@ RSpec.describe 'Branches' do
end
describe 'Delete unprotected branch on Overview' do
- it 'removes branch after confirmation', :js do
+ it 'removes branch after confirmation', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/239019' do
visit project_branches_filtered_path(project, state: 'all')
expect(all('.all-branches').last).to have_selector('li', count: 20)
diff --git a/spec/features/projects/ci/lint_spec.rb b/spec/features/projects/ci/lint_spec.rb
index ba063acbe70..ce435151b84 100644
--- a/spec/features/projects/ci/lint_spec.rb
+++ b/spec/features/projects/ci/lint_spec.rb
@@ -3,89 +3,122 @@
require 'spec_helper'
RSpec.describe 'CI Lint', :js do
+ include Spec::Support::Helpers::Features::EditorLiteSpecHelpers
+
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
- before do
- project.add_developer(user)
- sign_in(user)
-
- visit project_ci_lint_path(project)
- find('#ci-editor')
- execute_script("ace.edit('ci-editor').setValue(#{yaml_content.to_json});")
+ shared_examples 'correct ci linting process' do
+ describe 'YAML parsing' do
+ shared_examples 'validates the YAML' do
+ before do
+ stub_feature_flags(ci_lint_vue: false)
+ click_on 'Validate'
+ end
- # Ace editor updates a hidden textarea and it happens asynchronously
- wait_for('YAML content') do
- find('.ace_content').text.present?
- end
- end
+ context 'YAML is correct' do
+ let(:yaml_content) do
+ File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
+ end
- describe 'YAML parsing' do
- shared_examples 'validates the YAML' do
- before do
- click_on 'Validate'
- end
+ it 'parses Yaml and displays the jobs' do
+ expect(page).to have_content('Status: syntax is correct')
- context 'YAML is correct' do
- let(:yaml_content) do
- File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
+ within "table" do
+ aggregate_failures do
+ expect(page).to have_content('Job - rspec')
+ expect(page).to have_content('Job - spinach')
+ expect(page).to have_content('Deploy Job - staging')
+ expect(page).to have_content('Deploy Job - production')
+ end
+ end
+ end
end
- it 'parses Yaml and displays the jobs' do
- expect(page).to have_content('Status: syntax is correct')
+ context 'YAML is incorrect' do
+ let(:yaml_content) { 'value: cannot have :' }
- within "table" do
- aggregate_failures do
- expect(page).to have_content('Job - rspec')
- expect(page).to have_content('Job - spinach')
- expect(page).to have_content('Deploy Job - staging')
- expect(page).to have_content('Deploy Job - production')
- end
+ it 'displays information about an error' do
+ expect(page).to have_content('Status: syntax is incorrect')
+ expect(page).to have_selector(content_selector, text: yaml_content)
end
end
end
- context 'YAML is incorrect' do
- let(:yaml_content) { 'value: cannot have :' }
+ it_behaves_like 'validates the YAML'
- it 'displays information about an error' do
- expect(page).to have_content('Status: syntax is incorrect')
- expect(page).to have_selector('.ace_content', text: yaml_content)
+ context 'when Dry Run is checked' do
+ before do
+ check 'Simulate a pipeline created for the default branch'
end
+
+ it_behaves_like 'validates the YAML'
end
- end
- it_behaves_like 'validates the YAML'
+ describe 'YAML revalidate' do
+ let(:yaml_content) { 'my yaml content' }
- context 'when Dry Run is checked' do
+ it 'loads previous YAML content after validation' do
+ expect(page).to have_field('content', with: 'my yaml content', visible: false, type: 'textarea')
+ end
+ end
+ end
+
+ describe 'YAML clearing' do
before do
- check 'Simulate a pipeline created for the default branch'
+ click_on 'Clear'
end
- it_behaves_like 'validates the YAML'
+ context 'YAML is present' do
+ let(:yaml_content) do
+ File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
+ end
+
+ it 'YAML content is cleared' do
+ expect(page).to have_field('content', with: '', visible: false, type: 'textarea')
+ end
+ end
end
+ end
- describe 'YAML revalidate' do
- let(:yaml_content) { 'my yaml content' }
+ context 'with ACE editor' do
+ it_behaves_like 'correct ci linting process' do
+ let(:content_selector) { '.ace_content' }
- it 'loads previous YAML content after validation' do
- expect(page).to have_field('content', with: 'my yaml content', visible: false, type: 'textarea')
+ before do
+ stub_feature_flags(monaco_ci: false)
+ stub_feature_flags(ci_lint_vue: false)
+ project.add_developer(user)
+ sign_in(user)
+
+ visit project_ci_lint_path(project)
+ find('#ci-editor')
+ execute_script("ace.edit('ci-editor').setValue(#{yaml_content.to_json});")
+
+ # Ace editor updates a hidden textarea and it happens asynchronously
+ wait_for('YAML content') do
+ find(content_selector).text.present?
+ end
end
end
end
- describe 'YAML clearing' do
- before do
- click_on 'Clear'
- end
+ context 'with Editor Lite' do
+ it_behaves_like 'correct ci linting process' do
+ let(:content_selector) { '.content .view-lines' }
- context 'YAML is present' do
- let(:yaml_content) do
- File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
- end
+ before do
+ stub_feature_flags(monaco_ci: true)
+ stub_feature_flags(ci_lint_vue: false)
+ project.add_developer(user)
+ sign_in(user)
- it 'YAML content is cleared' do
- expect(page).to have_field('content', with: '', visible: false, type: 'textarea')
+ visit project_ci_lint_path(project)
+ editor_set_value(yaml_content)
+
+ wait_for('YAML content') do
+ find(content_selector).text.present?
+ end
end
end
end
diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb
index 63e5546b43c..04339d20d77 100644
--- a/spec/features/projects/clusters/gcp_spec.rb
+++ b/spec/features/projects/clusters/gcp_spec.rb
@@ -144,7 +144,7 @@ RSpec.describe 'Gcp Cluster', :js, :do_not_mock_admin_mode do
visit project_clusters_path(project)
click_link 'Add Kubernetes cluster'
- click_link 'Add existing cluster'
+ click_link 'Connect existing cluster'
end
it 'user sees the "Environment scope" field' do
diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb
index 450eaa7f004..9d0dc65093e 100644
--- a/spec/features/projects/clusters/user_spec.rb
+++ b/spec/features/projects/clusters/user_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'User Cluster', :js do
visit project_clusters_path(project)
click_link 'Add Kubernetes cluster'
- click_link 'Add existing cluster'
+ click_link 'Connect existing cluster'
end
context 'when user filled form with valid parameters' do
diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb
index c56a1ed1711..d674fbc457e 100644
--- a/spec/features/projects/clusters_spec.rb
+++ b/spec/features/projects/clusters_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe 'Clusters', :js do
context 'when user filled form with environment scope' do
before do
click_link 'Add Kubernetes cluster'
- click_link 'Add existing cluster'
+ click_link 'Connect existing cluster'
fill_in 'cluster_name', with: 'staging-cluster'
fill_in 'cluster_environment_scope', with: 'staging/*'
click_button 'Add Kubernetes cluster'
@@ -72,7 +72,7 @@ RSpec.describe 'Clusters', :js do
context 'when user updates duplicated environment scope' do
before do
click_link 'Add Kubernetes cluster'
- click_link 'Add existing cluster'
+ click_link 'Connect existing cluster'
fill_in 'cluster_name', with: 'staging-cluster'
fill_in 'cluster_environment_scope', with: '*'
fill_in 'cluster_platform_kubernetes_attributes_api_url', with: 'https://0.0.0.0'
diff --git a/spec/features/projects/commit/mini_pipeline_graph_spec.rb b/spec/features/projects/commit/mini_pipeline_graph_spec.rb
index 7bd3bce85d5..cf9b86f16bb 100644
--- a/spec/features/projects/commit/mini_pipeline_graph_spec.rb
+++ b/spec/features/projects/commit/mini_pipeline_graph_spec.rb
@@ -26,8 +26,6 @@ RSpec.describe 'Mini Pipeline Graph in Commit View', :js do
build.run
visit project_commit_path(project, project.commit.id)
- wait_for_all_requests
-
expect(page).to have_selector('.mr-widget-pipeline-graph')
first('.mini-pipeline-graph-dropdown-toggle').click
diff --git a/spec/features/projects/deploy_keys_spec.rb b/spec/features/projects/deploy_keys_spec.rb
index 70d47516246..6218578cac6 100644
--- a/spec/features/projects/deploy_keys_spec.rb
+++ b/spec/features/projects/deploy_keys_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe 'Project deploy keys', :js do
page.within(find('.qa-deploy-keys-settings')) do
expect(page).to have_selector('.deploy-key', count: 1)
- accept_confirm { find('.ic-remove').click }
+ accept_confirm { find('[data-testid="remove-icon"]').click }
wait_for_requests
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index a05910cd892..7f2ef61bcbe 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -106,7 +106,7 @@ RSpec.describe 'Environments page', :js do
expect(page).to have_css('.environments-container')
expect(page.all('.environment-name').length).to eq(1)
- expect(page.all('.ic-stop').length).to eq(0)
+ expect(page.all('[data-testid="stop-icon"]').length).to eq(0)
end
end
end
@@ -301,7 +301,7 @@ RSpec.describe 'Environments page', :js do
end
it 'has a dropdown for actionable jobs' do
- expect(page).to have_selector('.dropdown-new.btn.btn-default .ic-play')
+ expect(page).to have_selector('.dropdown-new.btn.btn-default [data-testid="play-icon"]')
end
it "has link to the delayed job's action" do
diff --git a/spec/features/projects/issues/design_management/user_paginates_designs_spec.rb b/spec/features/projects/issues/design_management/user_paginates_designs_spec.rb
index aff8951d9de..908e30478b2 100644
--- a/spec/features/projects/issues/design_management/user_paginates_designs_spec.rb
+++ b/spec/features/projects/issues/design_management/user_paginates_designs_spec.rb
@@ -8,57 +8,26 @@ RSpec.describe 'User paginates issue designs', :js do
let(:project) { create(:project_empty_repo, :public) }
let(:issue) { create(:issue, project: project) }
- context 'design_management_moved flag disabled' do
- before do
- stub_feature_flags(design_management_moved: false)
- enable_design_management
-
- create_list(:design, 2, :with_file, issue: issue)
- visit project_issue_path(project, issue)
- click_link 'Designs'
- wait_for_requests
- find('.js-design-list-item', match: :first).click
- end
-
- it 'paginates to next design' do
- expect(find('.js-previous-design')[:disabled]).to eq('true')
-
- page.within(find('.js-design-header')) do
- expect(page).to have_content('1 of 2')
- end
-
- find('.js-next-design').click
-
- expect(find('.js-previous-design')[:disabled]).not_to eq('true')
-
- page.within(find('.js-design-header')) do
- expect(page).to have_content('2 of 2')
- end
- end
+ before do
+ enable_design_management
+ create_list(:design, 2, :with_file, issue: issue)
+ visit project_issue_path(project, issue)
+ find('.js-design-list-item', match: :first).click
end
- context 'design_management_moved flag enabled' do
- before do
- enable_design_management
- create_list(:design, 2, :with_file, issue: issue)
- visit project_issue_path(project, issue)
- find('.js-design-list-item', match: :first).click
- end
+ it 'paginates to next design' do
+ expect(find('.js-previous-design')[:disabled]).to eq('true')
- it 'paginates to next design' do
- expect(find('.js-previous-design')[:disabled]).to eq('true')
-
- page.within(find('.js-design-header')) do
- expect(page).to have_content('1 of 2')
- end
+ page.within(find('.js-design-header')) do
+ expect(page).to have_content('1 of 2')
+ end
- find('.js-next-design').click
+ find('.js-next-design').click
- expect(find('.js-previous-design')[:disabled]).not_to eq('true')
+ expect(find('.js-previous-design')[:disabled]).not_to eq('true')
- page.within(find('.js-design-header')) do
- expect(page).to have_content('2 of 2')
- end
+ page.within(find('.js-design-header')) do
+ expect(page).to have_content('2 of 2')
end
end
end
diff --git a/spec/features/projects/issues/design_management/user_permissions_upload_spec.rb b/spec/features/projects/issues/design_management/user_permissions_upload_spec.rb
index 4e45312eac3..cfd8a4540ee 100644
--- a/spec/features/projects/issues/design_management/user_permissions_upload_spec.rb
+++ b/spec/features/projects/issues/design_management/user_permissions_upload_spec.rb
@@ -8,32 +8,13 @@ RSpec.describe 'User design permissions', :js do
let(:project) { create(:project_empty_repo, :public) }
let(:issue) { create(:issue, project: project) }
- context 'design_management_moved flag disabled' do
- before do
- enable_design_management
- stub_feature_flags(design_management_moved: false)
+ before do
+ enable_design_management
- visit project_issue_path(project, issue)
-
- click_link 'Designs'
-
- wait_for_requests
- end
-
- it 'user does not have permissions to upload design' do
- expect(page).not_to have_field('design_file')
- end
+ visit project_issue_path(project, issue)
end
- context 'design_management_moved flag enabled' do
- before do
- enable_design_management
-
- visit project_issue_path(project, issue)
- end
-
- it 'user does not have permissions to upload design' do
- expect(page).not_to have_field('design_file')
- end
+ it 'user does not have permissions to upload design' do
+ expect(page).not_to have_field('design_file')
end
end
diff --git a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
index 29a27992a0d..de1fcc9d787 100644
--- a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
+++ b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
@@ -11,81 +11,34 @@ RSpec.describe 'User uploads new design', :js do
before do
sign_in(user)
+ enable_design_management(feature_enabled)
+ visit project_issue_path(project, issue)
end
- context 'design_management_moved flag disabled' do
- before do
- enable_design_management(feature_enabled)
- stub_feature_flags(design_management_moved: false)
- visit project_issue_path(project, issue)
+ context "when the feature is available" do
+ let(:feature_enabled) { true }
- click_link 'Designs'
+ it 'uploads designs' do
+ upload_design(logo_fixture, count: 1)
- wait_for_requests
- end
-
- context "when the feature is available" do
- let(:feature_enabled) { true }
-
- it 'uploads designs' do
- upload_design(logo_fixture, count: 1)
-
- expect(page).to have_selector('.js-design-list-item', count: 1)
-
- within first('#designs-tab .js-design-list-item') do
- expect(page).to have_content('dk.png')
- end
+ expect(page).to have_selector('.js-design-list-item', count: 1)
- upload_design(gif_fixture, count: 2)
-
- # Known bug in the legacy implementation: new designs are inserted
- # in the beginning on the frontend.
- expect(page).to have_selector('.js-design-list-item', count: 2)
- expect(page.all('.js-design-list-item').map(&:text)).to eq(['banana_sample.gif', 'dk.png'])
+ within first('[data-testid="designs-root"] .js-design-list-item') do
+ expect(page).to have_content('dk.png')
end
- end
- context 'when the feature is not available' do
- let(:feature_enabled) { false }
+ upload_design(gif_fixture, count: 2)
- it 'shows the message about requirements' do
- expect(page).to have_content("To upload designs, you'll need to enable LFS.")
- end
+ expect(page).to have_selector('.js-design-list-item', count: 2)
+ expect(page.all('.js-design-list-item').map(&:text)).to eq(['dk.png', 'banana_sample.gif'])
end
end
- context 'design_management_moved flag enabled' do
- before do
- enable_design_management(feature_enabled)
- stub_feature_flags(design_management_moved: true)
- visit project_issue_path(project, issue)
- end
-
- context "when the feature is available" do
- let(:feature_enabled) { true }
+ context 'when the feature is not available' do
+ let(:feature_enabled) { false }
- it 'uploads designs' do
- upload_design(logo_fixture, count: 1)
-
- expect(page).to have_selector('.js-design-list-item', count: 1)
-
- within first('[data-testid="designs-root"] .js-design-list-item') do
- expect(page).to have_content('dk.png')
- end
-
- upload_design(gif_fixture, count: 2)
-
- expect(page).to have_selector('.js-design-list-item', count: 2)
- expect(page.all('.js-design-list-item').map(&:text)).to eq(['dk.png', 'banana_sample.gif'])
- end
- end
-
- context 'when the feature is not available' do
- let(:feature_enabled) { false }
-
- it 'shows the message about requirements' do
- expect(page).to have_content("To upload designs, you'll need to enable LFS.")
- end
+ it 'shows the message about requirements' do
+ expect(page).to have_content("To upload designs, you'll need to enable LFS and have admin enable hashed storage.")
end
end
diff --git a/spec/features/projects/issues/design_management/user_views_design_spec.rb b/spec/features/projects/issues/design_management/user_views_design_spec.rb
index 49245218e81..b513a4fe3fa 100644
--- a/spec/features/projects/issues/design_management/user_views_design_spec.rb
+++ b/spec/features/projects/issues/design_management/user_views_design_spec.rb
@@ -9,42 +9,19 @@ RSpec.describe 'User views issue designs', :js do
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:design) { create(:design, :with_file, issue: issue) }
- context 'design_management_moved flag disabled' do
- before do
- enable_design_management
- stub_feature_flags(design_management_moved: false)
+ before do
+ enable_design_management
- visit project_issue_path(project, issue)
-
- click_link 'Designs'
- end
-
- it 'opens design detail' do
- click_link design.filename
-
- page.within(find('.js-design-header')) do
- expect(page).to have_content(design.filename)
- end
-
- expect(page).to have_selector('.js-design-image')
- end
+ visit project_issue_path(project, issue)
end
- context 'design_management_moved flag enabled' do
- before do
- enable_design_management
+ it 'opens design detail' do
+ click_link design.filename
- visit project_issue_path(project, issue)
+ page.within(find('.js-design-header')) do
+ expect(page).to have_content(design.filename)
end
- it 'opens design detail' do
- click_link design.filename
-
- page.within(find('.js-design-header')) do
- expect(page).to have_content(design.filename)
- end
-
- expect(page).to have_selector('.js-design-image')
- end
+ expect(page).to have_selector('.js-design-image')
end
end
diff --git a/spec/features/projects/issues/design_management/user_views_designs_spec.rb b/spec/features/projects/issues/design_management/user_views_designs_spec.rb
index 772a9ffbe6f..46c772027ad 100644
--- a/spec/features/projects/issues/design_management/user_views_designs_spec.rb
+++ b/spec/features/projects/issues/design_management/user_views_designs_spec.rb
@@ -9,78 +9,37 @@ RSpec.describe 'User views issue designs', :js do
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:design) { create(:design, :with_file, issue: issue) }
- context 'design_management_moved flag disabled' do
- before do
- enable_design_management
- stub_feature_flags(design_management_moved: false)
- end
-
- context 'navigates from the issue view' do
- before do
- visit project_issue_path(project, issue)
- click_link 'Designs'
- wait_for_requests
- end
-
- it 'fetches list of designs' do
- expect(page).to have_selector('.js-design-list-item', count: 1)
- end
- end
-
- context 'navigates directly to the design collection view' do
- before do
- visit designs_project_issue_path(project, issue)
- end
+ before do
+ enable_design_management
+ end
- it 'expands the sidebar' do
- expect(page).to have_selector('.layout-page.right-sidebar-expanded')
- end
+ context 'navigates from the issue view' do
+ before do
+ visit project_issue_path(project, issue)
end
- context 'navigates directly to the individual design view' do
- before do
- visit designs_project_issue_path(project, issue, vueroute: design.filename)
- end
-
- it 'sees the design' do
- expect(page).to have_selector('.js-design-detail')
- end
+ it 'fetches list of designs' do
+ expect(page).to have_selector('.js-design-list-item', count: 1)
end
end
- context 'design_management_moved flag enabled' do
+ context 'navigates directly to the design collection view' do
before do
- enable_design_management
+ visit designs_project_issue_path(project, issue)
end
- context 'navigates from the issue view' do
- before do
- visit project_issue_path(project, issue)
- end
-
- it 'fetches list of designs' do
- expect(page).to have_selector('.js-design-list-item', count: 1)
- end
+ it 'expands the sidebar' do
+ expect(page).to have_selector('.layout-page.right-sidebar-expanded')
end
+ end
- context 'navigates directly to the design collection view' do
- before do
- visit designs_project_issue_path(project, issue)
- end
-
- it 'expands the sidebar' do
- expect(page).to have_selector('.layout-page.right-sidebar-expanded')
- end
+ context 'navigates directly to the individual design view' do
+ before do
+ visit designs_project_issue_path(project, issue, vueroute: design.filename)
end
- context 'navigates directly to the individual design view' do
- before do
- visit designs_project_issue_path(project, issue, vueroute: design.filename)
- end
-
- it 'sees the design' do
- expect(page).to have_selector('.js-design-detail')
- end
+ it 'sees the design' do
+ expect(page).to have_selector('.js-design-detail')
end
end
end
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index 0a6f204454e..404c3e93586 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -373,13 +373,29 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
let(:expire_at) { Time.now + 7.days }
context 'when user has ability to update job' do
- it 'keeps artifacts when keep button is clicked' do
- expect(page).to have_content 'The artifacts will be removed in'
+ context 'when artifacts are unlocked' do
+ before do
+ job.pipeline.unlocked!
+ end
- click_link 'Keep'
+ it 'keeps artifacts when keep button is clicked' do
+ expect(page).to have_content 'The artifacts will be removed in'
- expect(page).to have_no_link 'Keep'
- expect(page).to have_no_content 'The artifacts will be removed in'
+ click_link 'Keep'
+
+ expect(page).to have_no_link 'Keep'
+ expect(page).to have_no_content 'The artifacts will be removed in'
+ end
+ end
+
+ context 'when artifacts are locked' do
+ before do
+ job.pipeline.artifacts_locked!
+ end
+
+ it 'shows the keep button' do
+ expect(page).to have_link 'Keep'
+ end
end
end
@@ -395,9 +411,26 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
context 'when artifacts expired' do
let(:expire_at) { Time.now - 7.days }
- it 'does not have the Keep button' do
- expect(page).to have_content 'The artifacts were removed'
- expect(page).not_to have_link 'Keep'
+ context 'when artifacts are unlocked' do
+ before do
+ job.pipeline.unlocked!
+ end
+
+ it 'does not have the Keep button' do
+ expect(page).to have_content 'The artifacts were removed'
+ expect(page).not_to have_link 'Keep'
+ end
+ end
+
+ context 'when artifacts are locked' do
+ before do
+ job.pipeline.artifacts_locked!
+ end
+
+ it 'has the Keep button' do
+ expect(page).not_to have_content 'The artifacts were removed'
+ expect(page).to have_link 'Keep'
+ end
end
end
end
diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb
index 058cbfff662..30e32ad1366 100644
--- a/spec/features/projects/members/invite_group_spec.rb
+++ b/spec/features/projects/members/invite_group_spec.rb
@@ -112,7 +112,7 @@ RSpec.describe 'Project > Members > Invite group', :js do
let!(:group) { create(:group) }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
before do
diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb
index dcb901bcf11..07f65fe62df 100644
--- a/spec/features/projects/navbar_spec.rb
+++ b/spec/features/projects/navbar_spec.rb
@@ -18,6 +18,14 @@ RSpec.describe 'Project navbar' do
project.add_maintainer(user)
sign_in(user)
+
+ if Gitlab.ee?
+ insert_after_sub_nav_item(
+ _('Kubernetes'),
+ within: _('Operations'),
+ new_sub_nav_item_name: _('Feature Flags')
+ )
+ end
end
it_behaves_like 'verified navigation bar' do
diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb
index e1ace817c72..243579ee2f7 100644
--- a/spec/features/projects/pages_spec.rb
+++ b/spec/features/projects/pages_spec.rb
@@ -380,14 +380,14 @@ RSpec.shared_examples 'pages settings editing' do
expect(project).to be_pages_deployed
end
- it 'removes the pages' do
+ it 'removes the pages', :sidekiq_inline do
visit project_pages_path(project)
expect(page).to have_link('Remove pages')
accept_confirm { click_link 'Remove pages' }
- expect(page).to have_content('Pages were removed')
+ expect(page).to have_content('Pages were scheduled for removal')
expect(project.reload.pages_deployed?).to be_falsey
end
end
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index 26c46190e7d..f59dc5dd074 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -378,7 +378,7 @@ RSpec.describe 'Pipeline', :js do
find('.js-tests-tab-link').click
expect(page).to have_content('Jobs')
- expect(page).to have_selector('.js-tests-detail', visible: :all)
+ expect(page).to have_selector('[data-testid="tests-detail"]', visible: :all)
end
end
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index 8747b3ab54c..a9c196bb84b 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -311,7 +311,7 @@ RSpec.describe 'Pipelines', :js do
let!(:delayed_job) do
create(:ci_build, :scheduled,
pipeline: pipeline,
- name: 'delayed job',
+ name: 'delayed job 1',
stage: 'test')
end
@@ -327,7 +327,7 @@ RSpec.describe 'Pipelines', :js do
find('.js-pipeline-dropdown-manual-actions').click
time_diff = [0, delayed_job.scheduled_at - Time.now].max
- expect(page).to have_button('delayed job')
+ expect(page).to have_button('delayed job 1')
expect(page).to have_content(Time.at(time_diff).utc.strftime("%H:%M:%S"))
end
@@ -335,7 +335,7 @@ RSpec.describe 'Pipelines', :js do
let!(:delayed_job) do
create(:ci_build, :expired_scheduled,
pipeline: pipeline,
- name: 'delayed job',
+ name: 'delayed job 1',
stage: 'test')
end
@@ -349,7 +349,7 @@ RSpec.describe 'Pipelines', :js do
context 'when user played a delayed job immediately' do
before do
find('.js-pipeline-dropdown-manual-actions').click
- page.accept_confirm { click_button('delayed job') }
+ page.accept_confirm { click_button('delayed job 1') }
wait_for_requests
end
diff --git a/spec/features/projects/releases/user_creates_release_spec.rb b/spec/features/projects/releases/user_creates_release_spec.rb
new file mode 100644
index 00000000000..5d05a7e4c91
--- /dev/null
+++ b/spec/features/projects/releases/user_creates_release_spec.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User creates release', :js do
+ include Spec::Support::Helpers::Features::ReleasesHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:milestone_1) { create(:milestone, project: project, title: '1.1') }
+ let_it_be(:milestone_2) { create(:milestone, project: project, title: '1.2') }
+ let_it_be(:user) { create(:user) }
+
+ let(:new_page_url) { new_project_release_path(project) }
+ let(:show_feature_flag) { true }
+
+ before do
+ stub_feature_flags(release_show_page: show_feature_flag)
+
+ project.add_developer(user)
+
+ gitlab_sign_in(user)
+
+ visit new_page_url
+
+ wait_for_requests
+ end
+
+ 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_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))
+ end
+ end
+
+ it 'defaults the "Create from" dropdown to the project\'s default branch' do
+ expect(page.find('.ref-selector button')).to have_content(project.default_branch)
+ end
+
+ context 'when the "Save release" button is clicked' do
+ let(:tag_name) { 'v1.0' }
+ let(:release_title) { 'A most magnificent release' }
+ let(:release_notes) { 'Best. Release. **Ever.** :rocket:' }
+ let(:link_1) { { url: 'https://gitlab.example.com/runbook', title: 'An example runbook', type: 'runbook' } }
+ let(:link_2) { { url: 'https://gitlab.example.com/other', title: 'An example link', type: 'other' } }
+
+ before do
+ fill_out_form_and_submit
+ end
+
+ it 'creates a new release when "Create release" is clicked', :aggregate_failures do
+ release = project.releases.last
+
+ expect(release.tag).to eq(tag_name)
+ expect(release.sha).to eq(commit.id)
+ expect(release.name).to eq(release_title)
+ expect(release.milestones.first.title).to eq(milestone_1.title)
+ expect(release.milestones.second.title).to eq(milestone_2.title)
+ expect(release.description).to eq(release_notes)
+ expect(release.links.length).to eq(2)
+
+ link = release.links.find { |l| l.link_type == link_1[:type] }
+ expect(link.url).to eq(link_1[:url])
+ expect(link.name).to eq(link_1[:title])
+
+ link = release.links.find { |l| l.link_type == link_2[:type] }
+ expect(link.url).to eq(link_2[:url])
+ expect(link.name).to eq(link_2[:title])
+ end
+
+ it 'redirects to the dedicated page for the newly created release' do
+ release = project.releases.last
+
+ expect(page).to have_current_path(project_release_path(project, release))
+ end
+
+ context 'when the release_show_page feature flag is disabled' do
+ let(:show_feature_flag) { false }
+
+ it 'redirects to the main "Releases" page' do
+ expect(page).to have_current_path(project_releases_path(project))
+ end
+ end
+ end
+
+ context 'when the "Cancel" button is clicked' do
+ before do
+ click_link_or_button 'Cancel'
+
+ wait_for_all_requests
+ end
+
+ it 'redirects to the main "Releases" page' do
+ expect(page).to have_current_path(project_releases_path(project))
+ end
+
+ context 'when the URL includes a back_url query parameter' do
+ let(:back_path) { project_releases_path(project, params: { page: 2 }) }
+ let(:new_page_url) do
+ new_project_release_path(project, params: { back_url: back_path })
+ end
+
+ it 'redirects to the page specified with back_url' do
+ expect(page).to have_current_path(back_path)
+ end
+ end
+ end
+
+ def fill_out_form_and_submit
+ fill_tag_name(tag_name)
+
+ select_create_from(branch.name)
+
+ fill_release_title(release_title)
+
+ select_milestone(milestone_1.title, and_tab: false)
+ select_milestone(milestone_2.title)
+
+ # Focus the "Release notes" field by clicking instead of tabbing
+ # because tabbing to the field requires too many tabs
+ # (see https://gitlab.com/gitlab-org/gitlab/-/issues/238619)
+ find_field('Release notes').click
+ fill_release_notes(release_notes)
+
+ # Tab past the "assets" documentation link
+ focused_element.send_keys(:tab)
+
+ fill_asset_link(link_1)
+ add_another_asset_link
+ fill_asset_link(link_2)
+
+ # Submit using the Control+Enter shortcut
+ focused_element.send_keys([:control, :enter])
+
+ wait_for_all_requests
+ end
+
+ def branch
+ project.repository.branches.find { |b| b.name == 'feature' }
+ end
+
+ def commit
+ branch.dereferenced_target
+ end
+end
diff --git a/spec/features/projects/releases/user_views_releases_spec.rb b/spec/features/projects/releases/user_views_releases_spec.rb
index 962d5551631..993d3371904 100644
--- a/spec/features/projects/releases/user_views_releases_spec.rb
+++ b/spec/features/projects/releases/user_views_releases_spec.rb
@@ -13,37 +13,25 @@ RSpec.describe 'User views releases', :js do
project.add_guest(guest)
end
- context('when the user is a maintainer') do
- before do
- gitlab_sign_in(maintainer)
- end
-
- it 'sees the release' do
- visit project_releases_path(project)
-
- expect(page).to have_content(release.name)
- expect(page).to have_content(release.tag)
- expect(page).not_to have_content('Upcoming Release')
- end
-
- shared_examples 'asset link tests' do
- context 'when there is a link as an asset' do
- let!(:release_link) { create(:release_link, release: release, url: url ) }
- let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" }
- let(:direct_asset_link) { Gitlab::Routing.url_helpers.project_release_url(project, release) << release_link.filepath }
+ shared_examples 'releases page' do
+ context('when the user is a maintainer') do
+ before do
+ gitlab_sign_in(maintainer)
+ end
- it 'sees the link' do
- visit project_releases_path(project)
+ it 'sees the release' do
+ visit project_releases_path(project)
- page.within('.js-assets-list') do
- expect(page).to have_link release_link.name, href: direct_asset_link
- expect(page).not_to have_css('[data-testid="external-link-indicator"]')
- end
- end
+ expect(page).to have_content(release.name)
+ expect(page).to have_content(release.tag)
+ expect(page).not_to have_content('Upcoming Release')
+ end
- context 'when there is a link redirect' do
- let!(:release_link) { create(:release_link, release: release, name: 'linux-amd64 binaries', filepath: '/binaries/linux-amd64', url: url) }
+ shared_examples 'asset link tests' do
+ context 'when there is a link as an asset' do
+ let!(:release_link) { create(:release_link, release: release, url: url ) }
let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" }
+ let(:direct_asset_link) { Gitlab::Routing.url_helpers.project_release_url(project, release) << release_link.filepath }
it 'sees the link' do
visit project_releases_path(project)
@@ -53,77 +41,103 @@ RSpec.describe 'User views releases', :js do
expect(page).not_to have_css('[data-testid="external-link-indicator"]')
end
end
- end
- context 'when url points to external resource' do
- let(:url) { 'http://google.com/download' }
+ context 'when there is a link redirect' do
+ let!(:release_link) { create(:release_link, release: release, name: 'linux-amd64 binaries', filepath: '/binaries/linux-amd64', url: url) }
+ let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" }
- it 'sees that the link is external resource' do
- visit project_releases_path(project)
+ it 'sees the link' do
+ visit project_releases_path(project)
- page.within('.js-assets-list') do
- expect(page).to have_css('[data-testid="external-link-indicator"]')
+ page.within('.js-assets-list') do
+ expect(page).to have_link release_link.name, href: direct_asset_link
+ expect(page).not_to have_css('[data-testid="external-link-indicator"]')
+ end
+ end
+ end
+
+ context 'when url points to external resource' do
+ let(:url) { 'http://google.com/download' }
+
+ it 'sees that the link is external resource' do
+ visit project_releases_path(project)
+
+ page.within('.js-assets-list') do
+ expect(page).to have_css('[data-testid="external-link-indicator"]')
+ end
end
end
end
end
- end
- context 'when the release_asset_link_type feature flag is enabled' do
- before do
- stub_feature_flags(release_asset_link_type: true)
+ context 'when the release_asset_link_type feature flag is enabled' do
+ before do
+ stub_feature_flags(release_asset_link_type: true)
+ end
+
+ it_behaves_like 'asset link tests'
end
- it_behaves_like 'asset link tests'
- end
+ context 'when the release_asset_link_type feature flag is disabled' do
+ before do
+ stub_feature_flags(release_asset_link_type: false)
+ end
- context 'when the release_asset_link_type feature flag is disabled' do
- before do
- stub_feature_flags(release_asset_link_type: false)
+ it_behaves_like 'asset link tests'
end
- it_behaves_like 'asset link tests'
- end
+ context 'with an upcoming release' do
+ let(:tomorrow) { Time.zone.now + 1.day }
+ let!(:release) { create(:release, project: project, released_at: tomorrow ) }
- context 'with an upcoming release' do
- let(:tomorrow) { Time.zone.now + 1.day }
- let!(:release) { create(:release, project: project, released_at: tomorrow ) }
+ it 'sees the upcoming tag' do
+ visit project_releases_path(project)
- it 'sees the upcoming tag' do
- visit project_releases_path(project)
+ expect(page).to have_content('Upcoming Release')
+ end
+ end
+
+ context 'with a tag containing a slash' do
+ it 'sees the release' do
+ release = create :release, project: project, tag: 'debian/2.4.0-1'
+ visit project_releases_path(project)
- expect(page).to have_content('Upcoming Release')
+ expect(page).to have_content(release.name)
+ expect(page).to have_content(release.tag)
+ end
end
end
- context 'with a tag containing a slash' do
- it 'sees the release' do
- release = create :release, project: project, tag: 'debian/2.4.0-1'
+ context('when the user is a guest') do
+ before do
+ gitlab_sign_in(guest)
+ end
+
+ it 'renders release info except for Git-related data' do
visit project_releases_path(project)
- expect(page).to have_content(release.name)
- expect(page).to have_content(release.tag)
+ within('.release-block') do
+ expect(page).to have_content(release.description)
+
+ # The following properties (sometimes) include Git info,
+ # so they are not rendered for Guest users
+ expect(page).not_to have_content(release.name)
+ expect(page).not_to have_content(release.tag)
+ expect(page).not_to have_content(release.commit.short_id)
+ end
end
end
end
- context('when the user is a guest') do
+ context 'when the graphql_releases_page feature flag is enabled' do
+ it_behaves_like 'releases page'
+ end
+
+ context 'when the graphql_releases_page feature flag is disabled' do
before do
- gitlab_sign_in(guest)
+ stub_feature_flags(graphql_releases_page: false)
end
- it 'renders release info except for Git-related data' do
- visit project_releases_path(project)
-
- within('.release-block') do
- expect(page).to have_content(release.description)
-
- # The following properties (sometimes) include Git info,
- # so they are not rendered for Guest users
- expect(page).not_to have_content(release.name)
- expect(page).not_to have_content(release.tag)
- expect(page).not_to have_content(release.commit.short_id)
- end
- end
+ it_behaves_like 'releases page'
end
end
diff --git a/spec/features/projects/services/user_activates_asana_spec.rb b/spec/features/projects/services/user_activates_asana_spec.rb
index 3e24d106be0..e95e7e89fc2 100644
--- a/spec/features/projects/services/user_activates_asana_spec.rb
+++ b/spec/features/projects/services/user_activates_asana_spec.rb
@@ -12,6 +12,6 @@ RSpec.describe 'User activates Asana' do
click_test_then_save_integration
- expect(page).to have_content('Asana activated.')
+ expect(page).to have_content('Asana settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_assembla_spec.rb b/spec/features/projects/services/user_activates_assembla_spec.rb
index 2e49f4caa82..63cc424a641 100644
--- a/spec/features/projects/services/user_activates_assembla_spec.rb
+++ b/spec/features/projects/services/user_activates_assembla_spec.rb
@@ -13,8 +13,8 @@ RSpec.describe 'User activates Assembla' do
visit_project_integration('Assembla')
fill_in('Token', with: 'verySecret')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('Assembla activated.')
+ expect(page).to have_content('Assembla settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_atlassian_bamboo_ci_spec.rb b/spec/features/projects/services/user_activates_atlassian_bamboo_ci_spec.rb
index 7b89b9ac4a7..a9d91454670 100644
--- a/spec/features/projects/services/user_activates_atlassian_bamboo_ci_spec.rb
+++ b/spec/features/projects/services/user_activates_atlassian_bamboo_ci_spec.rb
@@ -16,9 +16,9 @@ RSpec.describe 'User activates Atlassian Bamboo CI' do
fill_in('Username', with: 'user')
fill_in('Password', with: 'verySecret')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('Atlassian Bamboo CI activated.')
+ expect(page).to have_content('Atlassian Bamboo CI settings saved and active.')
# Password field should not be filled in.
click_link('Atlassian Bamboo CI')
diff --git a/spec/features/projects/services/user_activates_emails_on_push_spec.rb b/spec/features/projects/services/user_activates_emails_on_push_spec.rb
index 40947027146..5a075fc61e8 100644
--- a/spec/features/projects/services/user_activates_emails_on_push_spec.rb
+++ b/spec/features/projects/services/user_activates_emails_on_push_spec.rb
@@ -9,8 +9,8 @@ RSpec.describe 'User activates Emails on push' do
visit_project_integration('Emails on push')
fill_in('Recipients', with: 'qa@company.name')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('Emails on push activated.')
+ expect(page).to have_content('Emails on push settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_flowdock_spec.rb b/spec/features/projects/services/user_activates_flowdock_spec.rb
index 9581d718400..4a4d7bbecfd 100644
--- a/spec/features/projects/services/user_activates_flowdock_spec.rb
+++ b/spec/features/projects/services/user_activates_flowdock_spec.rb
@@ -15,8 +15,8 @@ RSpec.describe 'User activates Flowdock' do
visit_project_integration('Flowdock')
fill_in('Token', with: 'verySecret')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('Flowdock activated.')
+ expect(page).to have_content('Flowdock settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_hipchat_spec.rb b/spec/features/projects/services/user_activates_hipchat_spec.rb
index a2820c4bb0f..cffb780e05d 100644
--- a/spec/features/projects/services/user_activates_hipchat_spec.rb
+++ b/spec/features/projects/services/user_activates_hipchat_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe 'User activates HipChat', :js do
include_context 'project service activation'
- context 'with standart settings' do
+ context 'with standard settings' do
before do
stub_request(:post, /.*api.hipchat.com.*/)
end
@@ -15,9 +15,9 @@ RSpec.describe 'User activates HipChat', :js do
fill_in('Room', with: 'gitlab')
fill_in('Token', with: 'verySecret')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('HipChat activated.')
+ expect(page).to have_content('HipChat settings saved and active.')
end
end
@@ -32,9 +32,9 @@ RSpec.describe 'User activates HipChat', :js do
fill_in('Token', with: 'secretCustom')
fill_in('Server', with: 'https://chat.example.com')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('HipChat activated.')
+ expect(page).to have_content('HipChat settings saved and active.')
end
end
end
diff --git a/spec/features/projects/services/user_activates_irker_spec.rb b/spec/features/projects/services/user_activates_irker_spec.rb
index fad40fa6085..e4d92dc30ff 100644
--- a/spec/features/projects/services/user_activates_irker_spec.rb
+++ b/spec/features/projects/services/user_activates_irker_spec.rb
@@ -10,8 +10,8 @@ RSpec.describe 'User activates Irker (IRC gateway)' do
check('Colorize messages')
fill_in('Recipients', with: 'irc://chat.freenode.net/#commits')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('Irker (IRC gateway) activated.')
+ expect(page).to have_content('Irker (IRC gateway) settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_issue_tracker_spec.rb b/spec/features/projects/services/user_activates_issue_tracker_spec.rb
index 4f25794d058..1aec8883395 100644
--- a/spec/features/projects/services/user_activates_issue_tracker_spec.rb
+++ b/spec/features/projects/services/user_activates_issue_tracker_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe 'User activates issue tracker', :js do
let(:url) { 'http://tracker.example.com' }
def fill_form(disable: false, skip_new_issue_url: false)
- click_active_toggle if disable
+ click_active_checkbox if disable
fill_in 'service_project_url', with: url
fill_in 'service_issues_url', with: "#{url}/:id"
@@ -16,7 +16,7 @@ RSpec.describe 'User activates issue tracker', :js do
fill_in 'service_new_issue_url', with: url unless skip_new_issue_url
end
- shared_examples 'external issue tracker activation' do |tracker:, skip_new_issue_url: false|
+ shared_examples 'external issue tracker activation' do |tracker:, skip_new_issue_url: false, skip_test: false|
describe 'user sets and activates the Service' do
context 'when the connection test succeeds' do
before do
@@ -25,11 +25,15 @@ RSpec.describe 'User activates issue tracker', :js do
visit_project_integration(tracker)
fill_form(skip_new_issue_url: skip_new_issue_url)
- click_test_integration
+ if skip_test
+ click_save_integration
+ else
+ click_test_then_save_integration(expect_test_to_fail: false)
+ end
end
it 'activates the service' do
- expect(page).to have_content("#{tracker} activated.")
+ expect(page).to have_content("#{tracker} settings saved and active.")
expect(current_path).to eq(edit_project_service_path(project, tracker.parameterize(separator: '_')))
end
@@ -47,9 +51,13 @@ RSpec.describe 'User activates issue tracker', :js do
visit_project_integration(tracker)
fill_form(skip_new_issue_url: skip_new_issue_url)
- click_test_then_save_integration
+ if skip_test
+ click_button('Save changes')
+ else
+ click_test_then_save_integration
+ end
- expect(page).to have_content("#{tracker} activated.")
+ expect(page).to have_content("#{tracker} settings saved and active.")
expect(current_path).to eq(edit_project_service_path(project, tracker.parameterize(separator: '_')))
end
end
@@ -64,7 +72,7 @@ RSpec.describe 'User activates issue tracker', :js do
end
it 'saves but does not activate the service' do
- expect(page).to have_content("#{tracker} settings saved, but not activated.")
+ expect(page).to have_content("#{tracker} settings saved, but not active.")
expect(current_path).to eq(edit_project_service_path(project, tracker.parameterize(separator: '_')))
end
@@ -80,4 +88,5 @@ RSpec.describe 'User activates issue tracker', :js do
it_behaves_like 'external issue tracker activation', tracker: 'YouTrack', skip_new_issue_url: true
it_behaves_like 'external issue tracker activation', tracker: 'Bugzilla'
it_behaves_like 'external issue tracker activation', tracker: 'Custom Issue Tracker'
+ it_behaves_like 'external issue tracker activation', tracker: 'EWM', skip_test: true
end
diff --git a/spec/features/projects/services/user_activates_jetbrains_teamcity_ci_spec.rb b/spec/features/projects/services/user_activates_jetbrains_teamcity_ci_spec.rb
index 8ee369eb6ec..72881054c6c 100644
--- a/spec/features/projects/services/user_activates_jetbrains_teamcity_ci_spec.rb
+++ b/spec/features/projects/services/user_activates_jetbrains_teamcity_ci_spec.rb
@@ -18,8 +18,8 @@ RSpec.describe 'User activates JetBrains TeamCity CI' do
fill_in('Username', with: 'user')
fill_in('Password', with: 'verySecret')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('JetBrains TeamCity CI activated.')
+ expect(page).to have_content('JetBrains TeamCity CI settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_jira_spec.rb b/spec/features/projects/services/user_activates_jira_spec.rb
index 483671c4b5b..85afc54be48 100644
--- a/spec/features/projects/services/user_activates_jira_spec.rb
+++ b/spec/features/projects/services/user_activates_jira_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe 'User activates Jira', :js do
include_context 'project service activation'
include_context 'project service Jira context'
- describe 'user sets and activates Jira Service' do
+ describe 'user tests Jira Service' do
context 'when Jira connection test succeeds' do
before do
server_info = { key: 'value' }.to_json
@@ -14,11 +14,11 @@ RSpec.describe 'User activates Jira', :js do
visit_project_integration('Jira')
fill_form
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
end
it 'activates the Jira service' do
- expect(page).to have_content('Jira activated.')
+ expect(page).to have_content('Jira settings saved and active.')
expect(current_path).to eq(edit_project_service_path(project, :jira))
end
@@ -54,21 +54,24 @@ RSpec.describe 'User activates Jira', :js do
fill_form
click_test_then_save_integration
- expect(page).to have_content('Jira activated.')
+ expect(page).to have_content('Jira settings saved and active.')
expect(current_path).to eq(edit_project_service_path(project, :jira))
end
end
end
describe 'user disables the Jira Service' do
+ include JiraServiceHelper
+
before do
+ stub_jira_service_test
visit_project_integration('Jira')
fill_form(disable: true)
- click_button('Save changes')
+ click_save_integration
end
it 'saves but does not activate the Jira service' do
- expect(page).to have_content('Jira settings saved, but not activated.')
+ expect(page).to have_content('Jira settings saved, but not active.')
expect(current_path).to eq(edit_project_service_path(project, :jira))
end
diff --git a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb
index 6ddffb710a8..32519b14d4e 100644
--- a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb
+++ b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb
@@ -28,21 +28,21 @@ RSpec.describe 'Set up Mattermost slash commands', :js do
token = ('a'..'z').to_a.join
fill_in 'service_token', with: token
- click_active_toggle
- click_on 'Save changes'
+ click_active_checkbox
+ click_save_integration
expect(current_path).to eq(edit_project_service_path(project, :mattermost_slash_commands))
- expect(page).to have_content('Mattermost slash commands settings saved, but not activated.')
+ expect(page).to have_content('Mattermost slash commands settings saved, but not active.')
end
it 'redirects to the integrations page after activating' do
token = ('a'..'z').to_a.join
fill_in 'service_token', with: token
- click_on 'Save changes'
+ click_save_integration
expect(current_path).to eq(edit_project_service_path(project, :mattermost_slash_commands))
- expect(page).to have_content('Mattermost slash commands activated.')
+ expect(page).to have_content('Mattermost slash commands settings saved and active.')
end
it 'shows the add to mattermost button' do
diff --git a/spec/features/projects/services/user_activates_packagist_spec.rb b/spec/features/projects/services/user_activates_packagist_spec.rb
index 70cf612bb2a..87303cf8fb4 100644
--- a/spec/features/projects/services/user_activates_packagist_spec.rb
+++ b/spec/features/projects/services/user_activates_packagist_spec.rb
@@ -16,6 +16,6 @@ RSpec.describe 'User activates Packagist' do
click_test_then_save_integration
- expect(page).to have_content('Packagist activated.')
+ expect(page).to have_content('Packagist settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_pivotaltracker_spec.rb b/spec/features/projects/services/user_activates_pivotaltracker_spec.rb
index 8e99c6e303b..83f66d4fa7b 100644
--- a/spec/features/projects/services/user_activates_pivotaltracker_spec.rb
+++ b/spec/features/projects/services/user_activates_pivotaltracker_spec.rb
@@ -13,8 +13,8 @@ RSpec.describe 'User activates PivotalTracker' do
visit_project_integration('PivotalTracker')
fill_in('Token', with: 'verySecret')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('PivotalTracker activated.')
+ expect(page).to have_content('PivotalTracker settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_prometheus_spec.rb b/spec/features/projects/services/user_activates_prometheus_spec.rb
index 89b1f447c32..b89e89d250f 100644
--- a/spec/features/projects/services/user_activates_prometheus_spec.rb
+++ b/spec/features/projects/services/user_activates_prometheus_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'User activates Prometheus' do
click_button('Save changes')
- expect(page).not_to have_content('Prometheus activated.')
+ expect(page).not_to have_content('Prometheus settings saved and active.')
expect(page).to have_content('Fields on this page has been deprecated.')
end
end
diff --git a/spec/features/projects/services/user_activates_pushover_spec.rb b/spec/features/projects/services/user_activates_pushover_spec.rb
index 789cc30a42e..3cfd069032a 100644
--- a/spec/features/projects/services/user_activates_pushover_spec.rb
+++ b/spec/features/projects/services/user_activates_pushover_spec.rb
@@ -17,8 +17,8 @@ RSpec.describe 'User activates Pushover' do
select('High Priority', from: 'Priority')
select('Bike', from: 'Sound')
- click_test_integration
+ click_test_then_save_integration(expect_test_to_fail: false)
- expect(page).to have_content('Pushover activated.')
+ expect(page).to have_content('Pushover settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_slack_notifications_spec.rb b/spec/features/projects/services/user_activates_slack_notifications_spec.rb
index 20e2bd3f085..2a880e05e0f 100644
--- a/spec/features/projects/services/user_activates_slack_notifications_spec.rb
+++ b/spec/features/projects/services/user_activates_slack_notifications_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe 'User activates Slack notifications', :js do
click_test_then_save_integration
- expect(page).to have_content('Slack notifications activated.')
+ expect(page).to have_content('Slack notifications settings saved and active.')
end
end
diff --git a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
index afe6855d6ad..3994f55caee 100644
--- a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
+++ b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
@@ -21,11 +21,11 @@ RSpec.describe 'Slack slash commands', :js do
it 'redirects to the integrations page after saving but not activating' do
fill_in 'Token', with: 'token'
- click_active_toggle
+ click_active_checkbox
click_on 'Save'
expect(current_path).to eq(edit_project_service_path(project, :slack_slash_commands))
- expect(page).to have_content('Slack slash commands settings saved, but not activated.')
+ expect(page).to have_content('Slack slash commands settings saved, but not active.')
end
it 'redirects to the integrations page after activating' do
@@ -33,7 +33,7 @@ RSpec.describe 'Slack slash commands', :js do
click_on 'Save'
expect(current_path).to eq(edit_project_service_path(project, :slack_slash_commands))
- expect(page).to have_content('Slack slash commands activated.')
+ expect(page).to have_content('Slack slash commands settings saved and active.')
end
it 'shows the correct trigger url' do
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
index 8beecedf85f..8c7b7bc70a2 100644
--- a/spec/features/projects/settings/repository_settings_spec.rb
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -70,7 +70,7 @@ RSpec.describe 'Projects > Settings > Repository settings' do
project.deploy_keys << private_deploy_key
visit project_settings_repository_path(project)
- find('.deploy-key', text: private_deploy_key.title).find('.ic-pencil').click
+ find('.deploy-key', text: private_deploy_key.title).find('[data-testid="pencil-icon"]').click
fill_in 'deploy_key_title', with: 'updated_deploy_key'
check 'deploy_key_deploy_keys_projects_attributes_0_can_push'
@@ -84,7 +84,7 @@ RSpec.describe 'Projects > Settings > Repository settings' do
project.deploy_keys << public_deploy_key
visit project_settings_repository_path(project)
- find('.deploy-key', text: public_deploy_key.title).find('.ic-pencil').click
+ find('.deploy-key', text: public_deploy_key.title).find('[data-testid="pencil-icon"]').click
check 'deploy_key_deploy_keys_projects_attributes_0_can_push'
click_button 'Save changes'
@@ -102,7 +102,7 @@ RSpec.describe 'Projects > Settings > Repository settings' do
find('.js-deployKeys-tab-available_project_keys').click
- find('.deploy-key', text: private_deploy_key.title).find('.ic-pencil').click
+ find('.deploy-key', text: private_deploy_key.title).find('[data-testid="pencil-icon"]').click
fill_in 'deploy_key_title', with: 'updated_deploy_key'
click_button 'Save changes'
@@ -116,7 +116,7 @@ RSpec.describe 'Projects > Settings > Repository settings' do
project.deploy_keys << private_deploy_key
visit project_settings_repository_path(project)
- accept_confirm { find('.deploy-key', text: private_deploy_key.title).find('.ic-remove').click }
+ accept_confirm { find('.deploy-key', text: private_deploy_key.title).find('[data-testid="remove-icon"]').click }
expect(page).not_to have_content(private_deploy_key.title)
end
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 6088ea31661..1ff976eb800 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' do
it 'shows errors for invalid project path' do
change_path(project, 'foo&bar')
- expect(page).to have_field 'Path', with: 'foo&bar'
+ expect(page).to have_field 'Path', with: 'gitlab'
expect(page).to have_content "Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'"
end
end
diff --git a/spec/features/projects/show/user_sees_readme_spec.rb b/spec/features/projects/show/user_sees_readme_spec.rb
index 250f707948e..6a5b9472be8 100644
--- a/spec/features/projects/show/user_sees_readme_spec.rb
+++ b/spec/features/projects/show/user_sees_readme_spec.rb
@@ -14,4 +14,25 @@ RSpec.describe 'Projects > Show > User sees README' do
expect(page).to have_content 'testme'
end
end
+
+ context 'obeying robots.txt' do
+ before do
+ Gitlab::Testing::RobotsBlockerMiddleware.block_requests!
+ end
+
+ after do
+ Gitlab::Testing::RobotsBlockerMiddleware.allow_requests!
+ end
+
+ # For example, see this regression we had in
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39520
+ it 'does not block the requests necessary to load the project README', :js do
+ visit project_path(project)
+ wait_for_requests
+
+ page.within('.readme-holder') do
+ expect(page).to have_content 'testme'
+ end
+ end
+ end
end
diff --git a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb
index afa9de5ce86..81736fefae9 100644
--- a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb
+++ b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb
@@ -226,7 +226,7 @@ RSpec.describe 'Projects > Show > User sees setup shortcut buttons' do
expect(project.repository.gitlab_ci_yml).to be_nil
page.within('.project-buttons') do
- expect(page).to have_link('Set up CI/CD', href: presenter.add_ci_yml_path)
+ expect(page).to have_link('Set up CI/CD', href: presenter.add_ci_yml_ide_path)
end
end
diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb
index 3db870f229a..503246bbdcf 100644
--- a/spec/features/projects/snippets/create_snippet_spec.rb
+++ b/spec/features/projects/snippets/create_snippet_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Projects > Snippets > Create Snippet', :js do
include DropzoneHelper
+ include Spec::Support::Helpers::Features::SnippetSpecHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) do
@@ -16,96 +17,115 @@ RSpec.describe 'Projects > Snippets > Create Snippet', :js do
let(:file_content) { 'Hello World!' }
let(:md_description) { 'My Snippet **Description**' }
let(:description) { 'My Snippet Description' }
+ let(:snippet_title_field) { 'project_snippet_title' }
- before do
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
+ shared_examples 'snippet creation' do
+ def fill_form
+ snippet_fill_in_form(title: title, content: file_content, description: md_description)
+ end
- sign_in(user)
+ it 'shows collapsible description input' do
+ collapsed = description_field
- visit new_project_snippet_path(project)
- end
+ expect(page).not_to have_field(snippet_description_field)
+ expect(collapsed).to be_visible
- def description_field
- find('.js-description-input').find('input,textarea')
- end
+ collapsed.click
- def fill_form
- fill_in 'project_snippet_title', with: title
+ expect(page).to have_field(snippet_description_field)
+ expect(collapsed).not_to be_visible
+ end
- # Click placeholder first to expand full description field
- description_field.click
- fill_in 'project_snippet_description', with: md_description
+ it 'creates a new snippet' do
+ fill_form
+ click_button('Create snippet')
+ wait_for_requests
- page.within('.file-editor') do
- el = find('.inputarea')
- el.send_keys file_content
+ expect(page).to have_content(title)
+ expect(page).to have_content(file_content)
+ page.within(snippet_description_view_selector) do
+ expect(page).to have_content(description)
+ expect(page).to have_selector('strong')
+ end
end
- end
- it 'shows collapsible description input' do
- collapsed = description_field
+ it 'uploads a file when dragging into textarea' do
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
- expect(page).not_to have_field('project_snippet_description')
- expect(collapsed).to be_visible
+ expect(snippet_description_value).to have_content('banana_sample')
- collapsed.click
+ click_button('Create snippet')
+ wait_for_requests
- expect(page).to have_field('project_snippet_description')
- expect(collapsed).not_to be_visible
- end
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/#{Regexp.escape(project.full_path)}/uploads/\h{32}/banana_sample\.gif\z})
+ end
+
+ context 'when the git operation fails' do
+ let(:error) { 'Error creating the snippet' }
- it 'creates a new snippet' do
- fill_form
- click_button('Create snippet')
- wait_for_requests
+ before do
+ allow_next_instance_of(Snippets::CreateService) do |instance|
+ allow(instance).to receive(:create_commit).and_raise(StandardError, error)
+ end
- expect(page).to have_content(title)
- expect(page).to have_content(file_content)
- page.within('.snippet-header .description') do
- expect(page).to have_content(description)
- expect(page).to have_selector('strong')
+ fill_form
+
+ click_button('Create snippet')
+ wait_for_requests
+ end
+
+ it 'renders the new page and displays the error' do
+ expect(page).to have_content(error)
+ expect(page).to have_content('New Snippet')
+ end
end
end
- it 'uploads a file when dragging into textarea' do
- fill_form
- dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ context 'Vue application' do
+ let(:snippet_description_field) { 'snippet-description' }
+ let(:snippet_description_view_selector) { '.snippet-header .snippet-description' }
- expect(page.find_field('project_snippet_description').value).to have_content('banana_sample')
+ before do
+ sign_in(user)
+
+ visit new_project_snippet_path(project)
+ end
- click_button('Create snippet')
- wait_for_requests
+ it_behaves_like 'snippet creation'
- link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
- expect(link).to match(%r{/#{Regexp.escape(project.full_path)}/uploads/\h{32}/banana_sample\.gif\z})
- end
+ it 'does not allow submitting the form without title and content' do
+ fill_in snippet_title_field, with: title
- it 'displays validation errors' do
- fill_in 'project_snippet_title', with: title
- click_button('Create snippet')
- wait_for_requests
+ expect(page).not_to have_button('Create snippet')
- expect(page).to have_selector('#error_explanation')
+ snippet_fill_in_form(title: title, content: file_content)
+ expect(page).to have_button('Create snippet')
+ end
end
- context 'when the git operation fails' do
- let(:error) { 'Error creating the snippet' }
+ context 'non-Vue application' do
+ let(:snippet_description_field) { 'project_snippet_description' }
+ let(:snippet_description_view_selector) { '.snippet-header .description' }
before do
- allow_next_instance_of(Snippets::CreateService) do |instance|
- allow(instance).to receive(:create_commit).and_raise(StandardError, error)
- end
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
- fill_form
+ sign_in(user)
+ visit new_project_snippet_path(project)
+ end
+
+ it_behaves_like 'snippet creation'
+
+ it 'displays validation errors' do
+ fill_in snippet_title_field, with: title
click_button('Create snippet')
wait_for_requests
- end
- it 'renders the new page and displays the error' do
- expect(page).to have_content(error)
- expect(page).to have_content('New Snippet')
+ expect(page).to have_selector('#error_explanation')
end
end
end
diff --git a/spec/features/projects/snippets/user_updates_snippet_spec.rb b/spec/features/projects/snippets/user_updates_snippet_spec.rb
index a40113bd93e..193eaa9576a 100644
--- a/spec/features/projects/snippets/user_updates_snippet_spec.rb
+++ b/spec/features/projects/snippets/user_updates_snippet_spec.rb
@@ -3,57 +3,81 @@
require 'spec_helper'
RSpec.describe 'Projects > Snippets > User updates a snippet', :js do
+ include Spec::Support::Helpers::Features::SnippetSpecHelpers
+
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) }
let_it_be(:snippet, reload: true) { create(:project_snippet, :repository, project: project, author: user) }
- before do
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
+ let(:snippet_title_field) { 'project_snippet_title' }
+ def bootstrap_snippet
project.add_maintainer(user)
sign_in(user)
- visit(project_snippet_path(project, snippet))
+ page.visit(edit_project_snippet_path(project, snippet))
- page.within('.detail-page-header') do
- first(:link, 'Edit').click
- end
wait_for_all_requests
end
- it 'displays the snippet blob path and content' do
- blob = snippet.blobs.first
+ shared_examples 'snippet update' do
+ it 'displays the snippet blob path and content' do
+ blob = snippet.blobs.first
- aggregate_failures do
- expect(page.find_field('project_snippet_file_name').value).to eq blob.path
- expect(page.find('.file-content')).to have_content(blob.data.strip)
- expect(page.find('.snippet-file-content', visible: false).value).to eq blob.data
+ aggregate_failures do
+ expect(snippet_get_first_blob_path).to eq blob.path
+ expect(snippet_get_first_blob_value).to have_content(blob.data.strip)
+ end
end
- end
- it 'updates a snippet' do
- fill_in('project_snippet_title', with: 'Snippet new title')
- click_button('Save')
+ it 'updates a snippet' do
+ fill_in('project_snippet_title', with: 'Snippet new title')
+ click_button('Save')
+
+ expect(page).to have_content('Snippet new title')
+ end
+
+ context 'when the git operation fails' do
+ before do
+ allow_next_instance_of(Snippets::UpdateService) do |instance|
+ allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
+ end
+
+ fill_in(snippet_title_field, with: 'Snippet new title')
+ fill_in(snippet_blob_path_field, match: :first, with: 'new_file_name')
+
+ click_button('Save')
+ end
- expect(page).to have_content('Snippet new title')
+ it 'renders edit page and displays the error' do
+ expect(page.find('.flash-container')).to have_content('Error updating the snippet - Error Message')
+ expect(page).to have_content('Edit Snippet')
+ end
+ end
end
- context 'when the git operation fails' do
+ context 'Vue application' do
before do
- allow_next_instance_of(Snippets::UpdateService) do |instance|
- allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
- end
+ bootstrap_snippet
+ end
- fill_in('project_snippet_title', with: 'Snippet new title')
- fill_in('project_snippet_file_name', with: 'new_file_name')
+ it_behaves_like 'snippet update' do
+ let(:snippet_blob_path_field) { 'snippet_file_name' }
+ let(:snippet_blob_content_selector) { '.file-content' }
+ end
+ end
- click_button('Save')
+ context 'non-Vue application' do
+ before do
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
+
+ bootstrap_snippet
end
- it 'renders edit page and displays the error' do
- expect(page.find('.flash-container span').text).to eq('Error updating the snippet - Error Message')
- expect(page).to have_content('Edit Snippet')
+ it_behaves_like 'snippet update' do
+ let(:snippet_blob_path_field) { 'project_snippet_file_name' }
+ let(:snippet_blob_content_selector) { '.file-content' }
end
end
end
diff --git a/spec/features/projects/user_sees_sidebar_spec.rb b/spec/features/projects/user_sees_sidebar_spec.rb
index 1d443e0b339..50d7b353c46 100644
--- a/spec/features/projects/user_sees_sidebar_spec.rb
+++ b/spec/features/projects/user_sees_sidebar_spec.rb
@@ -6,10 +6,6 @@ RSpec.describe 'Projects > User sees sidebar' do
let(:user) { create(:user) }
let(:project) { create(:project, :private, public_builds: false, namespace: user.namespace) }
- before do
- stub_feature_flags(vue_issuables_list: false)
- end
-
# NOTE: See documented behaviour https://design.gitlab.com/regions/navigation#contextual-navigation
context 'on different viewports', :js do
include MobileHelpers
@@ -134,6 +130,7 @@ RSpec.describe 'Projects > User sees sidebar' do
context 'as guest' do
let(:guest) { create(:user) }
+ let!(:issue) { create(:issue, :opened, project: project, author: guest) }
before do
project.add_guest(guest)
diff --git a/spec/features/projects/view_on_env_spec.rb b/spec/features/projects/view_on_env_spec.rb
index 6f78f888c12..d220db01c24 100644
--- a/spec/features/projects/view_on_env_spec.rb
+++ b/spec/features/projects/view_on_env_spec.rb
@@ -9,8 +9,6 @@ RSpec.describe 'View on environment', :js do
let(:user) { project.creator }
before do
- stub_feature_flags(diffs_batch_load: false)
-
project.add_maintainer(user)
end
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
index 8eba2c98595..8f2fb9e827c 100644
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ b/spec/features/projects/wiki/markdown_preview_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe 'Projects > Wiki > User previews markdown changes', :js do
let(:wiki_page) { create(:wiki_page, wiki: project.wiki, title: 'home', content: '[some link](other-page)') }
let(:wiki_content) do
<<-HEREDOC
+Some text so key event for [ does not trigger an incorrect replacement.
[regular link](regular)
[relative link 1](../relative)
[relative link 2](./relative)
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 3577498c3b4..970500985ae 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -159,7 +159,7 @@ RSpec.describe 'Project' do
describe 'remove forked relationship', :js do
let(:user) { create(:user) }
- let(:project) { fork_project(create(:project, :public), user, namespace_id: user.namespace) }
+ let(:project) { fork_project(create(:project, :public), user, namespace: user.namespace) }
before do
sign_in user
diff --git a/spec/features/search/user_searches_for_issues_spec.rb b/spec/features/search/user_searches_for_issues_spec.rb
index e9943347522..900ed35adea 100644
--- a/spec/features/search/user_searches_for_issues_spec.rb
+++ b/spec/features/search/user_searches_for_issues_spec.rb
@@ -6,7 +6,13 @@ RSpec.describe 'User searches for issues', :js do
let(:user) { create(:user) }
let(:project) { create(:project, namespace: user.namespace) }
let!(:issue1) { create(:issue, title: 'Foo', project: project) }
- let!(:issue2) { create(:issue, title: 'Bar', project: project) }
+ let!(:issue2) { create(:issue, :closed, :confidential, title: 'Bar', project: project) }
+
+ def search_for_issue(search)
+ fill_in('dashboard_search', with: search)
+ find('.btn-search').click
+ select_search_scope('Issues')
+ end
context 'when signed in' do
before do
@@ -19,9 +25,7 @@ RSpec.describe 'User searches for issues', :js do
include_examples 'top right search form'
it 'finds an issue' do
- fill_in('dashboard_search', with: issue1.title)
- find('.btn-search').click
- select_search_scope('Issues')
+ search_for_issue(issue1.title)
page.within('.results') do
expect(page).to have_link(issue1.title)
@@ -29,6 +33,40 @@ RSpec.describe 'User searches for issues', :js do
end
end
+ it 'hides confidential icon for non-confidential issues' do
+ search_for_issue(issue1.title)
+
+ page.within('.results') do
+ expect(page).not_to have_css('[data-testid="eye-slash-icon"]')
+ end
+ end
+
+ it 'shows confidential icon for confidential issues' do
+ search_for_issue(issue2.title)
+
+ page.within('.results') do
+ expect(page).to have_css('[data-testid="eye-slash-icon"]')
+ end
+ end
+
+ it 'shows correct badge for open issues' do
+ search_for_issue(issue1.title)
+
+ page.within('.results') do
+ expect(page).to have_css('.badge-success')
+ expect(page).not_to have_css('.badge-info')
+ end
+ end
+
+ it 'shows correct badge for closed issues' do
+ search_for_issue(issue2.title)
+
+ page.within('.results') do
+ expect(page).not_to have_css('.badge-success')
+ expect(page).to have_css('.badge-info')
+ end
+ end
+
context 'when on a project page' do
it 'finds an issue' do
find('.js-search-project-dropdown').click
@@ -37,9 +75,7 @@ RSpec.describe 'User searches for issues', :js do
click_link(project.full_name)
end
- fill_in('dashboard_search', with: issue1.title)
- find('.btn-search').click
- select_search_scope('Issues')
+ search_for_issue(issue1.title)
page.within('.results') do
expect(page).to have_link(issue1.title)
@@ -50,22 +86,33 @@ RSpec.describe 'User searches for issues', :js do
end
context 'when signed out' do
- let(:project) { create(:project, :public) }
+ context 'when block_anonymous_global_searches is disabled' do
+ let(:project) { create(:project, :public) }
- before do
- visit(search_path)
- end
+ before do
+ stub_feature_flags(block_anonymous_global_searches: false)
+ visit(search_path)
+ end
- include_examples 'top right search form'
+ include_examples 'top right search form'
- it 'finds an issue' do
- fill_in('dashboard_search', with: issue1.title)
- find('.btn-search').click
- select_search_scope('Issues')
+ it 'finds an issue' do
+ search_for_issue(issue1.title)
- page.within('.results') do
- expect(page).to have_link(issue1.title)
- expect(page).not_to have_link(issue2.title)
+ page.within('.results') do
+ expect(page).to have_link(issue1.title)
+ expect(page).not_to have_link(issue2.title)
+ end
+ end
+ end
+
+ context 'when block_anonymous_global_searches is enabled' do
+ before do
+ visit(search_path)
+ end
+
+ it 'is redirected to login page' do
+ expect(page).to have_content('You must be logged in to search across all of GitLab')
end
end
end
diff --git a/spec/features/search/user_searches_for_projects_spec.rb b/spec/features/search/user_searches_for_projects_spec.rb
index 7bb5a4da7d0..b64909dd42f 100644
--- a/spec/features/search/user_searches_for_projects_spec.rb
+++ b/spec/features/search/user_searches_for_projects_spec.rb
@@ -6,31 +6,44 @@ RSpec.describe 'User searches for projects' do
let!(:project) { create(:project, :public, name: 'Shop') }
context 'when signed out' do
- include_examples 'top right search form'
+ context 'when block_anonymous_global_searches is disabled' do
+ before do
+ stub_feature_flags(block_anonymous_global_searches: false)
+ end
- it 'finds a project' do
- visit(search_path)
+ include_examples 'top right search form'
- fill_in('dashboard_search', with: project.name[0..3])
- click_button('Search')
+ it 'finds a project' do
+ visit(search_path)
- expect(page).to have_link(project.name)
- end
+ fill_in('dashboard_search', with: project.name[0..3])
+ click_button('Search')
- it 'preserves the group being searched in' do
- visit(search_path(group_id: project.namespace.id))
+ expect(page).to have_link(project.name)
+ end
- submit_search('foo')
+ it 'preserves the group being searched in' do
+ visit(search_path(group_id: project.namespace.id))
- expect(find('#group_id', visible: false).value).to eq(project.namespace.id.to_s)
- end
+ submit_search('foo')
+
+ expect(find('#group_id', visible: false).value).to eq(project.namespace.id.to_s)
+ end
- it 'preserves the project being searched in' do
- visit(search_path(project_id: project.id))
+ it 'preserves the project being searched in' do
+ visit(search_path(project_id: project.id))
- submit_search('foo')
+ submit_search('foo')
+
+ expect(find('#project_id', visible: false).value).to eq(project.id.to_s)
+ end
+ end
- expect(find('#project_id', visible: false).value).to eq(project.id.to_s)
+ context 'when block_anonymous_global_searches is enabled' do
+ it 'is redirected to login page' do
+ visit(search_path)
+ expect(page).to have_content('You must be logged in to search across all of GitLab')
+ end
end
end
end
diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb
index 37e83d1e888..cfda25b9ab4 100644
--- a/spec/features/search/user_uses_header_search_field_spec.rb
+++ b/spec/features/search/user_uses_header_search_field_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe 'User uses header search field', :js do
context 'when using the keyboard shortcut' do
before do
- find('#search.js-autocomplete-disabled')
+ find('#search')
find('body').native.send_keys('s')
end
@@ -39,7 +39,7 @@ RSpec.describe 'User uses header search field', :js do
context 'when clicking the search field' do
before do
- page.find('#search.js-autocomplete-disabled').click
+ page.find('#search').click
end
it 'shows category search dropdown' do
diff --git a/spec/features/snippets/spam_snippets_spec.rb b/spec/features/snippets/spam_snippets_spec.rb
index e6a9467a3d7..1483ba4bf8f 100644
--- a/spec/features/snippets/spam_snippets_spec.rb
+++ b/spec/features/snippets/spam_snippets_spec.rb
@@ -11,8 +11,6 @@ RSpec.shared_examples_for 'snippet editor' do
before do
stub_feature_flags(allow_possible_spam: false)
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
Gitlab::CurrentSettings.update!(
@@ -125,5 +123,20 @@ end
RSpec.describe 'User creates snippet', :js do
let_it_be(:user) { create(:user) }
- it_behaves_like "snippet editor"
+ context 'Vue application' do
+ before do
+ stub_feature_flags(snippets_edit_vue: false)
+ end
+
+ it_behaves_like "snippet editor"
+ end
+
+ context 'non-Vue application' do
+ before do
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
+ end
+
+ it_behaves_like "snippet editor"
+ end
end
diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb
index f4c6536d6d3..eabca028b8c 100644
--- a/spec/features/snippets/user_creates_snippet_spec.rb
+++ b/spec/features/snippets/user_creates_snippet_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'User creates snippet', :js do
include DropzoneHelper
+ include Spec::Support::Helpers::Features::SnippetSpecHelpers
let_it_be(:user) { create(:user) }
@@ -12,149 +13,163 @@ RSpec.describe 'User creates snippet', :js do
let(:md_description) { 'My Snippet **Description**' }
let(:description) { 'My Snippet Description' }
let(:created_snippet) { Snippet.last }
-
- before do
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
- sign_in(user)
- end
+ let(:snippet_title_field) { 'personal_snippet_title' }
def description_field
find('.js-description-input').find('input,textarea')
end
- def fill_form
- fill_in 'personal_snippet_title', with: title
-
- # Click placeholder first to expand full description field
- description_field.click
- fill_in 'personal_snippet_description', with: md_description
-
- page.within('.file-editor') do
- el = find('.inputarea')
- el.send_keys file_content
+ shared_examples 'snippet creation' do
+ def fill_form
+ snippet_fill_in_form(title: title, content: file_content, description: md_description)
end
- end
-
- it 'Authenticated user creates a snippet' do
- visit new_snippet_path
- fill_form
+ it 'Authenticated user creates a snippet' do
+ fill_form
- click_button('Create snippet')
- wait_for_requests
+ click_button('Create snippet')
+ wait_for_requests
- expect(page).to have_content(title)
- page.within('.snippet-header .description') do
- expect(page).to have_content(description)
- expect(page).to have_selector('strong')
+ expect(page).to have_content(title)
+ page.within(snippet_description_view_selector) do
+ expect(page).to have_content(description)
+ expect(page).to have_selector('strong')
+ end
+ expect(page).to have_content(file_content)
end
- expect(page).to have_content(file_content)
- end
- it 'previews a snippet with file' do
- visit new_snippet_path
+ it 'uploads a file when dragging into textarea' do
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
- # Click placeholder first to expand full description field
- description_field.click
- fill_in 'personal_snippet_description', with: 'My Snippet'
- dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
- find('.js-md-preview-button').click
+ expect(snippet_description_value).to have_content('banana_sample')
- page.within('#new_personal_snippet .md-preview-holder') do
- expect(page).to have_content('My Snippet')
+ click_button('Create snippet')
+ wait_for_requests
link = find('a.no-attachment-icon img.js-lazy-loaded[alt="banana_sample"]')['src']
- expect(link).to match(%r{/uploads/-/system/user/#{user.id}/\h{32}/banana_sample\.gif\z})
+ expect(link).to match(%r{/uploads/-/system/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
- # Adds a cache buster for checking if the image exists as Selenium is now handling the cached requests
- # not anymore as requests when they come straight from memory cache.
reqs = inspect_requests { visit("#{link}?ran=#{SecureRandom.base64(20)}") }
expect(reqs.first.status_code).to eq(200)
end
- end
- it 'uploads a file when dragging into textarea' do
- visit new_snippet_path
+ context 'when the git operation fails' do
+ let(:error) { 'Error creating the snippet' }
- fill_form
+ before do
+ allow_next_instance_of(Snippets::CreateService) do |instance|
+ allow(instance).to receive(:create_commit).and_raise(StandardError, error)
+ end
- dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ fill_form
+ click_button('Create snippet')
+ wait_for_requests
+ end
+
+ it 'renders the new page and displays the error' do
+ expect(page).to have_content(error)
+ expect(page).to have_content('New Snippet')
- expect(page.find_field("personal_snippet_description").value).to have_content('banana_sample')
+ action = find('form.snippet-form')['action']
+ expect(action).to include("/snippets")
+ end
+ end
- click_button('Create snippet')
- wait_for_requests
+ context 'when snippets default visibility level is restricted' do
+ before do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PRIVATE],
+ default_snippet_visibility: Gitlab::VisibilityLevel::PRIVATE)
+ end
- link = find('a.no-attachment-icon img.js-lazy-loaded[alt="banana_sample"]')['src']
- expect(link).to match(%r{/uploads/-/system/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
+ it 'creates a snippet using the lowest available visibility level as default' do
+ visit new_snippet_path
- reqs = inspect_requests { visit("#{link}?ran=#{SecureRandom.base64(20)}") }
- expect(reqs.first.status_code).to eq(200)
- end
+ fill_form
- context 'when the git operation fails' do
- let(:error) { 'Error creating the snippet' }
+ click_button('Create snippet')
+ wait_for_requests
- before do
- allow_next_instance_of(Snippets::CreateService) do |instance|
- allow(instance).to receive(:create_commit).and_raise(StandardError, error)
+ expect(find('.blob-content')).to have_content(file_content)
+ expect(Snippet.last.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL)
end
+ end
- visit new_snippet_path
+ it_behaves_like 'personal snippet with references' do
+ let(:container) { snippet_description_view_selector }
+ let(:md_description) { references }
- fill_form
+ subject do
+ fill_form
+ click_button('Create snippet')
- click_button('Create snippet')
- wait_for_requests
+ wait_for_requests
+ end
end
+ end
+
+ context 'Vue application' do
+ let(:snippet_description_field) { 'snippet-description' }
+ let(:snippet_description_view_selector) { '.snippet-header .snippet-description' }
- it 'renders the new page and displays the error' do
- expect(page).to have_content(error)
- expect(page).to have_content('New Snippet')
+ before do
+ sign_in(user)
- action = find('form.snippet-form')['action']
- expect(action).to match(%r{/snippets\z})
+ visit new_snippet_path
end
- end
- it 'validation fails for the first time' do
- visit new_snippet_path
+ it_behaves_like 'snippet creation'
- fill_in 'personal_snippet_title', with: title
- click_button('Create snippet')
+ it 'validation fails for the first time' do
+ fill_in snippet_title_field, with: title
- expect(page).to have_selector('#error_explanation')
+ expect(page).not_to have_button('Create snippet')
+
+ snippet_fill_in_form(title: title, content: file_content)
+ expect(page).to have_button('Create snippet')
+ end
end
- context 'when snippets default visibility level is restricted' do
+ context 'non-Vue application' do
+ let(:snippet_description_field) { 'personal_snippet_description' }
+ let(:snippet_description_view_selector) { '.snippet-header .description' }
+
before do
- stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PRIVATE],
- default_snippet_visibility: Gitlab::VisibilityLevel::PRIVATE)
- end
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
+
+ sign_in(user)
- it 'creates a snippet using the lowest available visibility level as default' do
visit new_snippet_path
+ end
- fill_form
+ it_behaves_like 'snippet creation'
+ it 'validation fails for the first time' do
+ fill_in snippet_title_field, with: title
click_button('Create snippet')
- wait_for_requests
- expect(created_snippet.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL)
+ expect(page).to have_selector('#error_explanation')
end
- end
- it_behaves_like 'personal snippet with references' do
- let(:container) { '.snippet-header .description' }
- let(:md_description) { references }
+ it 'previews a snippet with file' do
+ # Click placeholder first to expand full description field
+ description_field.click
+ fill_in snippet_description_field, with: 'My Snippet'
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ find('.js-md-preview-button').click
- subject do
- visit new_snippet_path
- fill_form
- click_button('Create snippet')
+ page.within('.md-preview-holder') do
+ expect(page).to have_content('My Snippet')
- wait_for_requests
+ link = find('a.no-attachment-icon img.js-lazy-loaded[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/-/system/user/#{user.id}/\h{32}/banana_sample\.gif\z})
+
+ # Adds a cache buster for checking if the image exists as Selenium is now handling the cached requests
+ # not anymore as requests when they come straight from memory cache.
+ reqs = inspect_requests { visit("#{link}?ran=#{SecureRandom.base64(20)}") }
+ expect(reqs.first.status_code).to eq(200)
+ end
end
end
end
diff --git a/spec/features/snippets/user_edits_snippet_spec.rb b/spec/features/snippets/user_edits_snippet_spec.rb
index 5773904dedf..9a83eb58b63 100644
--- a/spec/features/snippets/user_edits_snippet_spec.rb
+++ b/spec/features/snippets/user_edits_snippet_spec.rb
@@ -4,87 +4,114 @@ require 'spec_helper'
RSpec.describe 'User edits snippet', :js do
include DropzoneHelper
+ include Spec::Support::Helpers::Features::SnippetSpecHelpers
let_it_be(:file_name) { 'test.rb' }
let_it_be(:content) { 'puts "test"' }
let_it_be(:user) { create(:user) }
let_it_be(:snippet, reload: true) { create(:personal_snippet, :repository, :public, file_name: file_name, content: content, author: user) }
- before do
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
+ let(:snippet_title_field) { 'personal_snippet_title' }
- sign_in(user)
+ shared_examples 'snippet editing' do
+ it 'displays the snippet blob path and content' do
+ blob = snippet.blobs.first
- visit edit_snippet_path(snippet)
- wait_for_all_requests
- end
+ aggregate_failures do
+ expect(snippet_get_first_blob_path).to eq blob.path
+ expect(snippet_get_first_blob_value).to have_content(blob.data.strip)
+ end
+ end
- it 'displays the snippet blob path and content' do
- blob = snippet.blobs.first
+ it 'updates the snippet' do
+ fill_in snippet_title_field, with: 'New Snippet Title'
- aggregate_failures do
- expect(page.find_field('personal_snippet_file_name').value).to eq blob.path
- expect(page.find('.file-content')).to have_content(blob.data.strip)
- expect(page.find('.snippet-file-content', visible: false).value).to eq blob.data
+ click_button('Save changes')
+ wait_for_requests
+
+ expect(page).to have_content('New Snippet Title')
end
- end
- it 'updates the snippet' do
- fill_in 'personal_snippet_title', with: 'New Snippet Title'
+ it 'updates the snippet with files attached' do
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ expect(snippet_description_value).to have_content('banana_sample')
- click_button('Save changes')
- wait_for_requests
+ click_button('Save changes')
+ wait_for_requests
- expect(page).to have_content('New Snippet Title')
- end
+ link = find('a.no-attachment-icon img:not(.lazy)[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/-/system/personal_snippet/#{snippet.id}/\h{32}/banana_sample\.gif\z})
+ end
- it 'updates the snippet with files attached' do
- dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
- expect(page.find_field('personal_snippet_description').value).to have_content('banana_sample')
+ it 'updates the snippet to make it internal' do
+ choose 'Internal'
- click_button('Save changes')
- wait_for_requests
+ click_button 'Save changes'
+ wait_for_requests
- link = find('a.no-attachment-icon img:not(.lazy)[alt="banana_sample"]')['src']
- expect(link).to match(%r{/uploads/-/system/personal_snippet/#{snippet.id}/\h{32}/banana_sample\.gif\z})
- end
+ expect(page).to have_no_selector('[data-testid="lock-icon"]')
+ expect(page).to have_selector('[data-testid="shield-icon"]')
+ end
- it 'updates the snippet to make it internal' do
- choose 'Internal'
+ it 'updates the snippet to make it public' do
+ choose 'Public'
- click_button 'Save changes'
- wait_for_requests
+ click_button 'Save changes'
+ wait_for_requests
- expect(page).to have_no_selector('[data-testid="lock-icon"]')
- expect(page).to have_selector('[data-testid="shield-icon"]')
- end
+ expect(page).to have_no_selector('[data-testid="lock-icon"]')
+ expect(page).to have_selector('[data-testid="earth-icon"]')
+ end
- it 'updates the snippet to make it public' do
- choose 'Public'
+ context 'when the git operation fails' do
+ before do
+ allow_next_instance_of(Snippets::UpdateService) do |instance|
+ allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
+ end
- click_button 'Save changes'
- wait_for_requests
+ fill_in snippet_title_field, with: 'New Snippet Title'
+ fill_in snippet_blob_path_field, with: 'new_file_name', match: :first
- expect(page).to have_no_selector('[data-testid="lock-icon"]')
- expect(page).to have_selector('[data-testid="earth-icon"]')
- end
+ click_button('Save changes')
+ end
- context 'when the git operation fails' do
- before do
- allow_next_instance_of(Snippets::UpdateService) do |instance|
- allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
+ it 'renders edit page and displays the error' do
+ expect(page.find('.flash-container')).to have_content('Error updating the snippet - Error Message')
+ expect(page).to have_content('Edit Snippet')
end
+ end
+ end
- fill_in 'personal_snippet_title', with: 'New Snippet Title'
- fill_in 'personal_snippet_file_name', with: 'new_file_name'
+ context 'Vue application' do
+ it_behaves_like 'snippet editing' do
+ let(:snippet_blob_path_field) { 'snippet_file_name' }
+ let(:snippet_blob_content_selector) { '.file-content' }
+ let(:snippet_description_field) { 'snippet-description' }
- click_button('Save changes')
+ before do
+ sign_in(user)
+
+ visit edit_snippet_path(snippet)
+ wait_for_all_requests
+ end
end
+ end
+
+ context 'non-Vue application' do
+ it_behaves_like 'snippet editing' do
+ let(:snippet_blob_path_field) { 'personal_snippet_file_name' }
+ let(:snippet_blob_content_selector) { '.file-content' }
+ let(:snippet_description_field) { 'personal_snippet_description' }
- it 'renders edit page and displays the error' do
- expect(page.find('.flash-container span').text).to eq('Error updating the snippet - Error Message')
- expect(page).to have_content('Edit Snippet')
+ before do
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
+
+ sign_in(user)
+
+ visit edit_snippet_path(snippet)
+ wait_for_all_requests
+ end
end
end
end
diff --git a/spec/features/static_site_editor_spec.rb b/spec/features/static_site_editor_spec.rb
index 9ae23b4bdec..b67e47b6ac4 100644
--- a/spec/features/static_site_editor_spec.rb
+++ b/spec/features/static_site_editor_spec.rb
@@ -13,7 +13,11 @@ RSpec.describe 'Static Site Editor' do
visit project_show_sse_path(project, 'master/README.md')
end
- it 'renders Static Site Editor page' do
- expect(page).to have_selector('#static-site-editor')
+ it 'renders Static Site Editor page with generated and file attributes' do
+ # assert generated config value is present
+ expect(page).to have_css('#static-site-editor[data-branch="master"]')
+
+ # assert file config value is present
+ expect(page).to have_css('#static-site-editor[data-static-site-generator="middleman"]')
end
end
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index fc3f8a94318..a9cfe794177 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -5,9 +5,9 @@ require 'spec_helper'
RSpec.describe 'Task Lists' do
include Warden::Test::Helpers
- let(:project) { create(:project, :public, :repository) }
- let(:user) { create(:user) }
- let(:user2) { create(:user) }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user2) { create(:user) }
let(:markdown) do
<<-MARKDOWN.strip_heredoc
@@ -72,12 +72,12 @@ RSpec.describe 'Task Lists' do
EOT
end
- before do
- Warden.test_mode!
-
+ before(:all) do
project.add_maintainer(user)
project.add_guest(user2)
+ end
+ before do
login_as(user)
end
diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb
index 8dbedc0a7ee..5762a54a717 100644
--- a/spec/features/u2f_spec.rb
+++ b/spec/features/u2f_spec.rb
@@ -3,22 +3,14 @@
require 'spec_helper'
RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
- def manage_two_factor_authentication
- click_on 'Manage two-factor authentication'
- expect(page).to have_content("Set up new U2F device")
- wait_for_requests
- end
+ include Spec::Support::Helpers::Features::TwoFactorHelpers
- 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 U2F device'
- expect(page).to have_content('Your device was successfully set up')
- fill_in "Pick a name", with: name
- click_on 'Register U2F device'
- u2f_device
+ before do
+ stub_feature_flags(webauthn: false)
end
+ it_behaves_like 'hardware device for 2fa', 'U2F'
+
describe "registration" do
let(:user) { create(:user) }
@@ -27,31 +19,7 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
user.update_attribute(:otp_required_for_login, true)
end
- describe 'when 2FA via OTP is disabled' do
- before do
- user.update_attribute(:otp_required_for_login, false)
- end
-
- it 'does not allow registering a new device' do
- visit profile_account_path
- click_on 'Enable two-factor authentication'
-
- expect(page).to have_button('Set up new U2F device', disabled: true)
- end
- end
-
describe 'when 2FA via OTP is enabled' do
- it 'allows registering a new device with a name' do
- visit profile_account_path
- manage_two_factor_authentication
- expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators")
-
- u2f_device = register_u2f_device
-
- expect(page).to have_content(u2f_device.name)
- expect(page).to have_content('Your U2F device was registered')
- end
-
it 'allows registering more than one device' do
visit profile_account_path
@@ -68,21 +36,6 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
expect(page).to have_content(second_device.name)
expect(U2fRegistration.count).to eq(2)
end
-
- it 'allows deleting a device' do
- visit profile_account_path
- manage_two_factor_authentication
- expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators")
-
- first_u2f_device = register_u2f_device
- second_u2f_device = register_u2f_device(name: 'My other device')
-
- accept_confirm { click_on "Delete", match: :first }
-
- expect(page).to have_content('Successfully deleted')
- expect(page.body).not_to match(first_u2f_device.name)
- expect(page).to have_content(second_u2f_device.name)
- end
end
it 'allows the same device to be registered for multiple users' do
@@ -111,9 +64,9 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
# Have the "u2f device" respond with bad data
page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };")
- click_on 'Set up new U2F device'
+ click_on 'Set up new device'
expect(page).to have_content('Your device was successfully set up')
- click_on 'Register U2F device'
+ click_on 'Register device'
expect(U2fRegistration.count).to eq(0)
expect(page).to have_content("The form contains the following error")
@@ -126,9 +79,9 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
# Failed registration
page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };")
- click_on 'Set up new U2F device'
+ click_on 'Set up new device'
expect(page).to have_content('Your device was successfully set up')
- click_on 'Register U2F device'
+ click_on 'Register device'
expect(page).to have_content("The form contains the following error")
# Successful registration
@@ -228,12 +181,12 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
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 U2F device needs to be set up.")
+ 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 U2F device needs to be set up.")
+ expect(page).to have_content("Your device needs to be set up.")
second_device = register_u2f_device(name: 'My other device')
gitlab_sign_out
@@ -249,50 +202,4 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
end
end
end
-
- describe 'fallback code authentication' do
- let(:user) { create(:user) }
-
- 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
-
- before do
- # Register and logout
- gitlab_sign_in(user)
- user.update_attribute(:otp_required_for_login, true)
- visit profile_account_path
- end
-
- describe 'when no u2f device is registered' do
- before do
- gitlab_sign_out
- gitlab_sign_in(user)
- end
-
- it 'shows the fallback otp code UI' do
- assert_fallback_ui(page)
- end
- end
-
- describe 'when a u2f device is registered' do
- before do
- manage_two_factor_authentication
- @u2f_device = register_u2f_device
- gitlab_sign_out
- gitlab_sign_in(user)
- 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')
-
- assert_fallback_ui(page)
- end
- end
- end
end
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
index 6f6ebe34c03..853c381fe6b 100644
--- a/spec/features/users/login_spec.rb
+++ b/spec/features/users/login_spec.rb
@@ -511,7 +511,7 @@ RSpec.describe 'Login' do
context 'within the grace period' do
it 'redirects to two-factor configuration page' do
- Timecop.freeze do
+ freeze_time do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb
index 332be055027..5fd0e677cd0 100644
--- a/spec/features/users/signup_spec.rb
+++ b/spec/features/users/signup_spec.rb
@@ -152,7 +152,6 @@ RSpec.shared_examples 'Signup' do
fill_in 'new_user_last_name', with: new_user.last_name
else
fill_in 'new_user_name', with: new_user.name
- fill_in 'new_user_email_confirmation', with: new_user.email
end
fill_in 'new_user_password', with: new_user.password
@@ -180,19 +179,13 @@ RSpec.shared_examples 'Signup' do
fill_in 'new_user_last_name', with: new_user.last_name
else
fill_in 'new_user_name', with: new_user.name
- fill_in 'new_user_email_confirmation', with: new_user.email
end
fill_in 'new_user_password', with: new_user.password
expect { click_button 'Register' }.to change { User.count }.by(1)
- if Gitlab::Experimentation.enabled?(:signup_flow)
- expect(current_path).to eq users_sign_up_welcome_path
- else
- expect(current_path).to eq dashboard_projects_path
- expect(page).to have_content("Please check your email (#{new_user.email}) to verify that you own this address and unlock the power of CI/CD.")
- end
+ expect(current_path).to eq users_sign_up_welcome_path
end
end
end
@@ -209,18 +202,12 @@ RSpec.shared_examples 'Signup' do
fill_in 'new_user_last_name', with: new_user.last_name
else
fill_in 'new_user_name', with: new_user.name
- fill_in 'new_user_email_confirmation', with: new_user.email.capitalize
end
fill_in 'new_user_password', with: new_user.password
click_button "Register"
- if Gitlab::Experimentation.enabled?(:signup_flow)
- expect(current_path).to eq users_sign_up_welcome_path
- else
- expect(current_path).to eq dashboard_projects_path
- expect(page).to have_content("Welcome! You have signed up successfully.")
- end
+ expect(current_path).to eq users_sign_up_welcome_path
end
end
@@ -240,18 +227,12 @@ RSpec.shared_examples 'Signup' do
fill_in 'new_user_last_name', with: new_user.last_name
else
fill_in 'new_user_name', with: new_user.name
- fill_in 'new_user_email_confirmation', with: new_user.email
end
fill_in 'new_user_password', with: new_user.password
click_button "Register"
- if Gitlab::Experimentation.enabled?(:signup_flow)
- expect(current_path).to eq users_sign_up_welcome_path
- else
- expect(current_path).to eq dashboard_projects_path
- expect(page).to have_content("Welcome! You have signed up successfully.")
- end
+ expect(current_path).to eq users_sign_up_welcome_path
end
end
end
@@ -275,14 +256,7 @@ RSpec.shared_examples 'Signup' do
click_button "Register"
expect(current_path).to eq user_registration_path
-
- if Gitlab::Experimentation.enabled?(:signup_flow)
- expect(page).to have_content("error prohibited this user from being saved")
- else
- expect(page).to have_content("errors prohibited this user from being saved")
- expect(page).to have_content("Email confirmation doesn't match")
- end
-
+ expect(page).to have_content("error prohibited this user from being saved")
expect(page).to have_content("Email has already been taken")
end
@@ -324,7 +298,6 @@ RSpec.shared_examples 'Signup' do
fill_in 'new_user_last_name', with: new_user.last_name
else
fill_in 'new_user_name', with: new_user.name
- fill_in 'new_user_email_confirmation', with: new_user.email
end
fill_in 'new_user_password', with: new_user.password
@@ -346,7 +319,6 @@ RSpec.shared_examples 'Signup' do
fill_in 'new_user_last_name', with: new_user.last_name
else
fill_in 'new_user_name', with: new_user.name
- fill_in 'new_user_email_confirmation', with: new_user.email
end
fill_in 'new_user_password', with: new_user.password
@@ -354,11 +326,7 @@ RSpec.shared_examples 'Signup' do
click_button "Register"
- if Gitlab::Experimentation.enabled?(:signup_flow)
- expect(current_path).to eq users_sign_up_welcome_path
- else
- expect(current_path).to eq dashboard_projects_path
- end
+ expect(current_path).to eq users_sign_up_welcome_path
end
end
@@ -393,7 +361,6 @@ RSpec.shared_examples 'Signup' do
fill_in 'new_user_last_name', with: new_user.last_name
else
fill_in 'new_user_name', with: new_user.name
- fill_in 'new_user_email_confirmation', with: new_user.email
end
fill_in 'new_user_password', with: new_user.password
@@ -415,7 +382,6 @@ RSpec.shared_examples 'Signup' do
fill_in 'new_user_last_name', with: new_user.last_name
else
fill_in 'new_user_name', with: new_user.name
- fill_in 'new_user_email_confirmation', with: new_user.email
end
fill_in 'new_user_password', with: new_user.password
@@ -425,6 +391,35 @@ RSpec.shared_examples 'Signup' do
end
end
end
+
+ it 'redirects to step 2 of the signup process, sets the role and redirects back' do
+ new_user = build_stubbed(:user)
+ visit new_user_registration_path
+
+ fill_in 'new_user_username', with: new_user.username
+ fill_in 'new_user_email', with: new_user.email
+
+ if Gitlab::Experimentation.enabled?(:signup_flow)
+ fill_in 'new_user_first_name', with: new_user.first_name
+ fill_in 'new_user_last_name', with: new_user.last_name
+ else
+ fill_in 'new_user_name', with: new_user.name
+ end
+
+ fill_in 'new_user_password', with: new_user.password
+ click_button 'Register'
+ visit new_project_path
+
+ expect(page).to have_current_path(users_sign_up_welcome_path)
+
+ select 'Software Developer', from: 'user_role'
+ click_button 'Get started!'
+ new_user = User.find_by_username(new_user.username)
+
+ expect(new_user.software_developer_role?).to be_truthy
+ expect(new_user.setup_for_company).to be_nil
+ expect(page).to have_current_path(new_project_path)
+ end
end
RSpec.shared_examples 'Signup name validation' do |field, max_length|
@@ -485,31 +480,6 @@ RSpec.describe 'With experimental flow' do
it_behaves_like 'Signup name validation', 'new_user_first_name', 127
it_behaves_like 'Signup name validation', 'new_user_last_name', 127
- describe 'when role is required' do
- it 'after registering, it redirects to step 2 of the signup process, sets the name and role and then redirects to the original requested url' do
- new_user = build_stubbed(:user)
- visit new_user_registration_path
- fill_in 'new_user_first_name', with: new_user.first_name
- fill_in 'new_user_last_name', with: new_user.last_name
- 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 'Register'
- visit new_project_path
-
- expect(page).to have_current_path(users_sign_up_welcome_path)
-
- select 'Software Developer', from: 'user_role'
- choose 'user_setup_for_company_true'
- click_button 'Get started!'
- new_user = User.find_by_username(new_user.username)
-
- expect(new_user.software_developer_role?).to be_truthy
- expect(new_user.setup_for_company).to be_truthy
- expect(page).to have_current_path(new_project_path)
- end
- end
-
context 'when terms_opt_in experimental is enabled' do
include TermsHelper
@@ -521,14 +491,13 @@ RSpec.describe 'With experimental flow' do
it 'terms are checked by default' do
new_user = build_stubbed(:user)
- visit new_user_registration_path
- fill_in 'new_user_username', with: new_user.username
- fill_in 'new_user_email', with: new_user.email
+ visit new_user_registration_path
fill_in 'new_user_first_name', with: new_user.first_name
fill_in 'new_user_last_name', with: new_user.last_name
+ 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 'Register'
expect(current_path).to eq users_sign_up_welcome_path
diff --git a/spec/features/webauthn_spec.rb b/spec/features/webauthn_spec.rb
new file mode 100644
index 00000000000..2ffb6bb3477
--- /dev/null
+++ b/spec/features/webauthn_spec.rb
@@ -0,0 +1,234 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Using WebAuthn Devices for Authentication', :js do
+ include Spec::Support::Helpers::Features::TwoFactorHelpers
+ let(:app_id) { "http://#{Capybara.current_session.server.host}:#{Capybara.current_session.server.port}" }
+
+ before do
+ WebAuthn.configuration.origin = app_id
+ end
+
+ it_behaves_like 'hardware device for 2fa', 'WebAuthn'
+
+ 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_webauthn_device
+ expect(page).to have_content('Your WebAuthn device was registered')
+
+ # Second device
+ second_device = register_webauthn_device(name: 'My other device')
+ 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
+ end
+
+ it 'allows the same device to be registered for multiple users' do
+ # First user
+ visit profile_account_path
+ manage_two_factor_authentication
+ webauthn_device = register_webauthn_device
+ expect(page).to have_content('Your WebAuthn 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_webauthn_device(webauthn_device, name: 'My other device')
+ 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 = function(_) {return Promise.resolve(mockResponse);}
+ JS
+ end
+
+ it "doesn't register the device if there are errors" do
+ visit profile_account_path
+ manage_two_factor_authentication
+
+ # Have the "webauthn device" respond with bad data
+ page.execute_script(mock_register_js)
+ click_on 'Set up new device'
+ expect(page).to have_content('Your device was successfully set up')
+ click_on 'Register device'
+
+ expect(WebauthnRegistration.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(mock_register_js)
+ 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_webauthn_device
+
+ expect(page).to have_content('Your WebAuthn device was registered')
+ expect(WebauthnRegistration.count).to eq(1)
+ 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) }
+
+ describe 'when there is only an U2F device' do
+ let!(:u2f_device) do
+ fake_device = U2F::FakeU2F.new(app_id) # "Client"
+ u2f = U2F::U2F.new(app_id) # "Server"
+
+ challenges = u2f.registration_requests.map(&:challenge)
+ device_response = fake_device.register_response(challenges[0])
+ device_registration_params = { device_response: device_response,
+ name: 'My device' }
+
+ U2fRegistration.register(user, app_id, device_registration_params, challenges)
+ FakeU2fDevice.new(page, 'My device', fake_device)
+ end
+
+ it 'falls back to U2F' do
+ gitlab_sign_in(user)
+
+ u2f_device.respond_to_u2f_authentication
+
+ expect(page).to have_css('.sign-out-link', visible: false)
+ end
+ end
+
+ describe 'when there is a WebAuthn device' do
+ 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 }
+
+ it 'allows logging in with the WebAuthn device' do
+ gitlab_sign_in(user)
+
+ webauthn_device.respond_to_webauthn_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 WebAuthn device' do
+ gitlab_sign_in(user)
+
+ webauthn_device.respond_to_webauthn_authentication
+
+ 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) }
+
+ 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
+ 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
+
+ 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
+
+ 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)
+
+ # 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)
+
+ gitlab_sign_out
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/ci/jobs_finder_spec.rb b/spec/finders/ci/jobs_finder_spec.rb
index e6680afa15c..a6a41c36489 100644
--- a/spec/finders/ci/jobs_finder_spec.rb
+++ b/spec/finders/ci/jobs_finder_spec.rb
@@ -36,53 +36,135 @@ RSpec.describe Ci::JobsFinder, '#execute' do
end
end
- context 'scope is present' do
- let(:jobs) { [job_1, job_2, job_3] }
-
- where(:scope, :index) do
- [
- ['pending', 0],
- ['running', 1],
- ['finished', 2]
- ]
+ context 'with ci_jobs_finder_refactor ff enabled' do
+ before do
+ stub_feature_flags(ci_jobs_finder_refactor: true)
end
- with_them do
- let(:params) { { scope: scope } }
+ context 'scope is present' do
+ let(:jobs) { [job_1, job_2, job_3] }
+
+ where(:scope, :index) do
+ [
+ ['pending', 0],
+ ['running', 1],
+ ['finished', 2]
+ ]
+ end
+
+ with_them do
+ let(:params) { { scope: scope } }
- it { expect(subject).to match_array([jobs[index]]) }
+ it { expect(subject).to match_array([jobs[index]]) }
+ end
end
- end
- end
- context 'a project is present' do
- subject { described_class.new(current_user: user, project: project, params: params).execute }
+ context 'scope is an array' do
+ let(:jobs) { [job_1, job_2, job_3] }
+ let(:params) {{ scope: ['running'] }}
- context 'user has access to the project' do
+ it 'filters by the job statuses in the scope' do
+ expect(subject).to match_array([job_2])
+ end
+ end
+ end
+
+ context 'with ci_jobs_finder_refactor ff disabled' do
before do
- project.add_maintainer(user)
+ stub_feature_flags(ci_jobs_finder_refactor: false)
end
- it 'returns jobs for the specified project' do
- expect(subject).to match_array([job_3])
+ context 'scope is present' do
+ let(:jobs) { [job_1, job_2, job_3] }
+
+ where(:scope, :index) do
+ [
+ ['pending', 0],
+ ['running', 1],
+ ['finished', 2]
+ ]
+ end
+
+ with_them do
+ let(:params) { { scope: scope } }
+
+ it { expect(subject).to match_array([jobs[index]]) }
+ end
end
end
+ end
- context 'user has no access to project builds' do
- before do
- project.add_guest(user)
+ context 'with ci_jobs_finder_refactor ff enabled' do
+ before do
+ stub_feature_flags(ci_jobs_finder_refactor: true)
+ end
+
+ context 'a project is present' do
+ subject { described_class.new(current_user: user, project: project, params: params).execute }
+
+ context 'user has access to the project' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'returns jobs for the specified project' do
+ expect(subject).to match_array([job_3])
+ end
end
- it 'returns no jobs' do
- expect(subject).to be_empty
+ context 'user has no access to project builds' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns no jobs' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'without user' do
+ let(:user) { nil }
+
+ it 'returns no jobs' do
+ expect(subject).to be_empty
+ end
end
end
+ end
- context 'without user' do
- let(:user) { nil }
+ context 'with ci_jobs_finder_refactor ff disabled' do
+ before do
+ stub_feature_flags(ci_jobs_finder_refactor: false)
+ end
+ context 'a project is present' do
+ subject { described_class.new(current_user: user, project: project, params: params).execute }
- it 'returns no jobs' do
- expect(subject).to be_empty
+ context 'user has access to the project' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'returns jobs for the specified project' do
+ expect(subject).to match_array([job_3])
+ end
+ end
+
+ context 'user has no access to project builds' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns no jobs' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'without user' do
+ let(:user) { nil }
+
+ it 'returns no jobs' do
+ expect(subject).to be_empty
+ end
end
end
end
diff --git a/spec/finders/concerns/finder_methods_spec.rb b/spec/finders/concerns/finder_methods_spec.rb
index 3e299c93eda..195449d70c3 100644
--- a/spec/finders/concerns/finder_methods_spec.rb
+++ b/spec/finders/concerns/finder_methods_spec.rb
@@ -7,8 +7,6 @@ RSpec.describe FinderMethods do
Class.new do
include FinderMethods
- attr_reader :current_user
-
def initialize(user)
@current_user = user
end
@@ -16,6 +14,10 @@ RSpec.describe FinderMethods do
def execute
Project.all.order(id: :desc)
end
+
+ private
+
+ attr_reader :current_user
end
end
diff --git a/spec/finders/design_management/designs_finder_spec.rb b/spec/finders/design_management/designs_finder_spec.rb
index 0133095827d..feb78a4bc4b 100644
--- a/spec/finders/design_management/designs_finder_spec.rb
+++ b/spec/finders/design_management/designs_finder_spec.rb
@@ -42,26 +42,6 @@ RSpec.describe DesignManagement::DesignsFinder do
is_expected.to eq([design3, design2, design1])
end
- context 'when the :reorder_designs feature is enabled for the project' do
- before do
- stub_feature_flags(reorder_designs: project)
- end
-
- it 'returns the designs sorted by their relative position' do
- is_expected.to eq([design3, design2, design1])
- end
- end
-
- context 'when the :reorder_designs feature is disabled' do
- before do
- stub_feature_flags(reorder_designs: false)
- end
-
- it 'returns the designs sorted by ID' do
- is_expected.to eq([design1, design2, design3])
- end
- end
-
context 'when argument is the ids of designs' do
let(:params) { { ids: [design1.id] } }
diff --git a/spec/finders/feature_flags_finder_spec.rb b/spec/finders/feature_flags_finder_spec.rb
new file mode 100644
index 00000000000..870447a1286
--- /dev/null
+++ b/spec/finders/feature_flags_finder_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlagsFinder do
+ include FeatureFlagHelpers
+
+ let(:finder) { described_class.new(project, user, params) }
+ let(:project) { create(:project) }
+ let(:user) { developer }
+ let(:developer) { create(:user) }
+ let(:reporter) { create(:user) }
+ let(:params) { {} }
+
+ before do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ describe '#execute' do
+ subject { finder.execute(args) }
+
+ let!(:feature_flag_1) { create(:operations_feature_flag, name: 'flag-a', project: project) }
+ let!(:feature_flag_2) { create(:operations_feature_flag, name: 'flag-b', project: project) }
+ let(:args) { {} }
+
+ it 'returns feature flags ordered by name' do
+ is_expected.to eq([feature_flag_1, feature_flag_2])
+ end
+
+ it 'preloads relations by default' do
+ expect(Operations::FeatureFlag).to receive(:preload_relations).and_call_original
+
+ subject
+ end
+
+ context 'when user is a reporter' do
+ let(:user) { reporter }
+
+ it 'returns an empty list' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when scope is given' do
+ let!(:feature_flag_1) { create(:operations_feature_flag, project: project, active: true) }
+ let!(:feature_flag_2) { create(:operations_feature_flag, project: project, active: false) }
+
+ context 'when scope is enabled' do
+ let(:params) { { scope: 'enabled' } }
+
+ it 'returns active feature flag' do
+ is_expected.to eq([feature_flag_1])
+ end
+ end
+
+ context 'when scope is disabled' do
+ let(:params) { { scope: 'disabled' } }
+
+ it 'returns inactive feature flag' do
+ is_expected.to eq([feature_flag_2])
+ end
+ end
+ end
+
+ context 'when preload option is false' do
+ let(:args) { { preload: false } }
+
+ it 'does not preload relations' do
+ expect(Operations::FeatureFlag).not_to receive(:preload_relations)
+
+ subject
+ end
+ end
+
+ context 'when new version flags are enabled' do
+ let!(:feature_flag_3) { create(:operations_feature_flag, :new_version_flag, name: 'flag-c', project: project) }
+
+ it 'returns new and legacy flags' do
+ is_expected.to eq([feature_flag_1, feature_flag_2, feature_flag_3])
+ end
+ end
+
+ context 'when new version flags are disabled' do
+ let!(:feature_flag_3) { create(:operations_feature_flag, :new_version_flag, name: 'flag-c', project: project) }
+
+ it 'returns only legacy flags' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ is_expected.to eq([feature_flag_1, feature_flag_2])
+ end
+ end
+ end
+end
diff --git a/spec/finders/fork_targets_finder_spec.rb b/spec/finders/fork_targets_finder_spec.rb
index 7208f46cfff..12f01227af8 100644
--- a/spec/finders/fork_targets_finder_spec.rb
+++ b/spec/finders/fork_targets_finder_spec.rb
@@ -35,5 +35,13 @@ RSpec.describe ForkTargetsFinder do
it 'returns all user manageable namespaces' do
expect(finder.execute).to match_array([user.namespace, maintained_group, owned_group, project.namespace])
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])
+ end
+
+ it 'returns groups relation when only_groups option is passed' do
+ expect(finder.execute(only_groups: true)).to include(a_kind_of(Group))
+ end
end
end
diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb
index 68b120db227..67e7de5921a 100644
--- a/spec/finders/group_members_finder_spec.rb
+++ b/spec/finders/group_members_finder_spec.rb
@@ -16,6 +16,7 @@ RSpec.describe GroupMembersFinder, '#execute' do
member1 = group.add_maintainer(user1)
member2 = group.add_maintainer(user2)
member3 = group.add_maintainer(user3)
+ create(:group_member, :minimal_access, user: create(:user), source: group)
result = described_class.new(group).execute
diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb
index 78764f79a6c..48e4c5dadc9 100644
--- a/spec/finders/groups_finder_spec.rb
+++ b/spec/finders/groups_finder_spec.rb
@@ -150,6 +150,14 @@ RSpec.describe GroupsFinder do
end
end
end
+
+ context 'being minimal access member of parent group' do
+ it 'do not return group with minimal_access access' do
+ create(:group_member, :minimal_access, user: user, source: parent_group)
+
+ is_expected.to contain_exactly(public_subgroup, internal_subgroup)
+ end
+ end
end
end
end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index fb7d4e808fe..dbf5abe64a5 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -3,6 +3,7 @@
require 'spec_helper'
RSpec.describe IssuesFinder do
+ using RSpec::Parameterized::TableSyntax
include_context 'IssuesFinder context'
describe '#execute' do
@@ -330,100 +331,139 @@ RSpec.describe IssuesFinder do
end
end
- context 'filtering by label' do
- let(:params) { { label_name: label.title } }
+ shared_examples ':label_name parameter' do
+ context 'filtering by label' do
+ let(:params) { { label_name: label.title } }
- it 'returns issues with that label' do
- expect(issues).to contain_exactly(issue2)
- end
+ it 'returns issues with that label' do
+ expect(issues).to contain_exactly(issue2)
+ end
- context 'using NOT' do
- let(:params) { { not: { label_name: label.title } } }
+ context 'using NOT' do
+ let(:params) { { not: { label_name: label.title } } }
- it 'returns issues that do not have that label' do
- expect(issues).to contain_exactly(issue1, issue3, issue4)
- end
+ it 'returns issues that do not have that label' do
+ expect(issues).to contain_exactly(issue1, issue3, issue4)
+ end
- # IssuableFinder first filters using the outer params (the ones not inside the `not` key.)
- # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param
- # do not take precedence over the outer params with the same name.
- context 'shadowing the same outside param' do
- let(:params) { { label_name: label2.title, not: { label_name: label.title } } }
+ # IssuableFinder first filters using the outer params (the ones not inside the `not` key.)
+ # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param
+ # do not take precedence over the outer params with the same name.
+ context 'shadowing the same outside param' do
+ let(:params) { { label_name: label2.title, not: { label_name: label.title } } }
- it 'does not take precedence over labels outside NOT' do
- expect(issues).to contain_exactly(issue3)
+ it 'does not take precedence over labels outside NOT' do
+ expect(issues).to contain_exactly(issue3)
+ end
end
- end
- context 'further filtering outside params' do
- let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } }
+ context 'further filtering outside params' do
+ let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } }
- it 'further filters on the returned resultset' do
- expect(issues).to be_empty
+ it 'further filters on the returned resultset' do
+ expect(issues).to be_empty
+ end
end
end
end
- end
- context 'filtering by multiple labels' do
- let(:params) { { label_name: [label.title, label2.title].join(',') } }
- let(:label2) { create(:label, project: project2) }
+ context 'filtering by multiple labels' do
+ let(:params) { { label_name: [label.title, label2.title].join(',') } }
+ let(:label2) { create(:label, project: project2) }
- before do
- create(:label_link, label: label2, target: issue2)
- end
+ before do
+ create(:label_link, label: label2, target: issue2)
+ end
- it 'returns the unique issues with all those labels' do
- expect(issues).to contain_exactly(issue2)
- end
+ it 'returns the unique issues with all those labels' do
+ expect(issues).to contain_exactly(issue2)
+ end
- context 'using NOT' do
- let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
+ context 'using NOT' do
+ let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
- it 'returns issues that do not have any of the labels provided' do
- expect(issues).to contain_exactly(issue1, issue4)
+ it 'returns issues that do not have any of the labels provided' do
+ expect(issues).to contain_exactly(issue1, issue4)
+ end
end
end
- end
- context 'filtering by a label that includes any or none in the title' do
- let(:params) { { label_name: [label.title, label2.title].join(',') } }
- let(:label) { create(:label, title: 'any foo', project: project2) }
- let(:label2) { create(:label, title: 'bar none', project: project2) }
+ context 'filtering by a label that includes any or none in the title' do
+ let(:params) { { label_name: [label.title, label2.title].join(',') } }
+ let(:label) { create(:label, title: 'any foo', project: project2) }
+ let(:label2) { create(:label, title: 'bar none', project: project2) }
- before do
- create(:label_link, label: label2, target: issue2)
- end
+ before do
+ create(:label_link, label: label2, target: issue2)
+ end
+
+ it 'returns the unique issues with all those labels' do
+ expect(issues).to contain_exactly(issue2)
+ end
- it 'returns the unique issues with all those labels' do
- expect(issues).to contain_exactly(issue2)
+ context 'using NOT' do
+ let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
+
+ it 'returns issues that do not have ANY ONE of the labels provided' do
+ expect(issues).to contain_exactly(issue1, issue4)
+ end
+ end
end
- context 'using NOT' do
- let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
+ context 'filtering by no label' do
+ let(:params) { { label_name: described_class::Params::FILTER_NONE } }
- it 'returns issues that do not have ANY ONE of the labels provided' do
+ it 'returns issues with no labels' do
expect(issues).to contain_exactly(issue1, issue4)
end
end
- end
- context 'filtering by no label' do
- let(:params) { { label_name: described_class::Params::FILTER_NONE } }
+ context 'filtering by any label' do
+ let(:params) { { label_name: described_class::Params::FILTER_ANY } }
+
+ it 'returns issues that have one or more label' do
+ create_list(:label_link, 2, label: create(:label, project: project2), target: issue3)
- it 'returns issues with no labels' do
- expect(issues).to contain_exactly(issue1, issue4)
+ expect(issues).to contain_exactly(issue2, issue3)
+ end
+ end
+
+ context 'when the same label exists on project and group levels' do
+ let(:issue1) { create(:issue, project: project1) }
+ let(:issue2) { create(:issue, project: project1) }
+
+ # Skipping validation to reproduce a "real-word" scenario.
+ # We still have legacy labels on PRD that have the same title on the group and project levels, example: `bug`
+ let(:project_label) { build(:label, title: 'somelabel', project: project1).tap { |r| r.save!(validate: false) } }
+ let(:group_label) { create(:group_label, title: 'somelabel', group: project1.group) }
+
+ let(:params) { { label_name: 'somelabel' } }
+
+ before do
+ create(:label_link, label: group_label, target: issue1)
+ create(:label_link, label: project_label, target: issue2)
+ end
+
+ it 'finds both issue records' do
+ expect(issues).to contain_exactly(issue1, issue2)
+ end
end
end
- context 'filtering by any label' do
- let(:params) { { label_name: described_class::Params::FILTER_ANY } }
+ context 'when `optimized_issuable_label_filter` feature flag is off' do
+ before do
+ stub_feature_flags(optimized_issuable_label_filter: false)
+ end
- it 'returns issues that have one or more label' do
- create_list(:label_link, 2, label: create(:label, project: project2), target: issue3)
+ it_behaves_like ':label_name parameter'
+ end
- expect(issues).to contain_exactly(issue2, issue3)
+ context 'when `optimized_issuable_label_filter` feature flag is on' do
+ before do
+ stub_feature_flags(optimized_issuable_label_filter: true)
end
+
+ it_behaves_like ':label_name parameter'
end
context 'filtering by issue term' do
@@ -949,10 +989,6 @@ RSpec.describe IssuesFinder do
describe '#use_cte_for_search?' do
let(:finder) { described_class.new(nil, params) }
- before do
- stub_feature_flags(attempt_group_search_optimizations: true)
- end
-
context 'when there is no search param' do
let(:params) { { attempt_group_search_optimizations: true } }
@@ -969,47 +1005,50 @@ RSpec.describe IssuesFinder do
end
end
- context 'when the attempt_group_search_optimizations flag is disabled' do
- let(:params) { { search: 'foo', attempt_group_search_optimizations: true } }
+ context 'when all conditions are met' do
+ context "uses group search optimization" do
+ let(:params) { { search: 'foo', attempt_group_search_optimizations: true } }
- before do
- stub_feature_flags(attempt_group_search_optimizations: false)
+ it 'returns true' do
+ expect(finder.use_cte_for_search?).to be_truthy
+ end
end
- it 'returns false' do
- expect(finder.use_cte_for_search?).to be_falsey
+ context "uses project search optimization" do
+ let(:params) { { search: 'foo', attempt_project_search_optimizations: true } }
+
+ it 'returns true' do
+ expect(finder.use_cte_for_search?).to be_truthy
+ end
end
end
+ end
- context 'when attempt_group_search_optimizations is unset and attempt_project_search_optimizations is set' do
- let(:params) { { search: 'foo', attempt_project_search_optimizations: true } }
+ describe '#parent_param=' do
+ let(:finder) { described_class.new(nil) }
- context 'and the corresponding feature flag is disabled' do
- before do
- stub_feature_flags(attempt_project_search_optimizations: false)
- end
+ subject { finder.parent_param = obj }
- it 'returns false' do
- expect(finder.use_cte_for_search?).to be_falsey
- end
- end
+ where(:klass, :param) do
+ :Project | :project_id
+ :Group | :group_id
+ end
- context 'and the corresponding feature flag is enabled' do
- before do
- stub_feature_flags(attempt_project_search_optimizations: true)
- end
+ with_them do
+ let(:obj) { Object.const_get(klass, false).new }
- it 'returns true' do
- expect(finder.use_cte_for_search?).to be_truthy
- end
+ it 'sets the params' do
+ subject
+
+ expect(finder.params[param]).to eq(obj)
end
end
- context 'when all conditions are met' do
- let(:params) { { search: 'foo', attempt_group_search_optimizations: true } }
+ context 'unexpected parent' do
+ let(:obj) { MergeRequest.new }
- it 'returns true' do
- expect(finder.use_cte_for_search?).to be_truthy
+ it 'raises an error' do
+ expect { subject }.to raise_error('Unexpected parent: MergeRequest')
end
end
end
diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb
index 3ef8d6a01aa..3530858e2de 100644
--- a/spec/finders/members_finder_spec.rb
+++ b/spec/finders/members_finder_spec.rb
@@ -45,6 +45,18 @@ RSpec.describe MembersFinder, '#execute' do
expect(result).to contain_exactly(member1)
end
+ it 'does not return members of parent group with minimal access' do
+ nested_group.request_access(user1)
+ member1 = group.add_maintainer(user2)
+ member2 = nested_group.add_maintainer(user3)
+ member3 = project.add_maintainer(user4)
+ create(:group_member, :minimal_access, user: create(:user), source: group)
+
+ result = described_class.new(project, user2).execute
+
+ expect(result).to contain_exactly(member1, member2, member3)
+ end
+
it 'includes only non-invite members if user do not have amdin permissions on project' do
create(:project_member, :invited, project: project, invite_email: create(:user).email)
member1 = project.add_maintainer(user1)
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index 5b86c891e47..4f86323c7c6 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -167,38 +167,56 @@ RSpec.describe MergeRequestsFinder do
end
end
- describe ':label_name parameter' do
- let(:common_labels) { create_list(:label, 3) }
- let(:distinct_labels) { create_list(:label, 3) }
- let(:merge_requests) do
- common_attrs = {
- source_project: project1, target_project: project1, author: user
- }
- distinct_labels.map do |label|
- labels = [label, *common_labels]
- create(:labeled_merge_request, :closed, labels: labels, **common_attrs)
+ shared_examples ':label_name parameter' do
+ describe ':label_name parameter' do
+ let(:common_labels) { create_list(:label, 3) }
+ let(:distinct_labels) { create_list(:label, 3) }
+ let(:merge_requests) do
+ common_attrs = {
+ source_project: project1, target_project: project1, author: user
+ }
+ distinct_labels.map do |label|
+ labels = [label, *common_labels]
+ create(:labeled_merge_request, :closed, labels: labels, **common_attrs)
+ end
end
- end
- def find(label_name)
- described_class.new(user, label_name: label_name).execute
- end
+ def find(label_name)
+ described_class.new(user, label_name: label_name).execute
+ end
+
+ it 'accepts a single label' do
+ found = find(distinct_labels.first.title)
+ common = find(common_labels.first.title)
+
+ expect(found).to contain_exactly(merge_requests.first)
+ expect(common).to match_array(merge_requests)
+ end
- it 'accepts a single label' do
- found = find(distinct_labels.first.title)
- common = find(common_labels.first.title)
+ it 'accepts an array of labels, all of which must match' do
+ all_distinct = find(distinct_labels.pluck(:title))
+ all_common = find(common_labels.pluck(:title))
- expect(found).to contain_exactly(merge_requests.first)
- expect(common).to match_array(merge_requests)
+ expect(all_distinct).to be_empty
+ expect(all_common).to match_array(merge_requests)
+ end
+ end
+ end
+
+ context 'when `optimized_issuable_label_filter` feature flag is off' do
+ before do
+ stub_feature_flags(optimized_issuable_label_filter: false)
end
- it 'accepts an array of labels, all of which must match' do
- all_distinct = find(distinct_labels.pluck(:title))
- all_common = find(common_labels.pluck(:title))
+ it_behaves_like ':label_name parameter'
+ end
- expect(all_distinct).to be_empty
- expect(all_common).to match_array(merge_requests)
+ context 'when `optimized_issuable_label_filter` feature flag is on' do
+ before do
+ stub_feature_flags(optimized_issuable_label_filter: true)
end
+
+ it_behaves_like ':label_name parameter'
end
it 'filters by source project id' do
diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb
index 29b6dc61386..8ae19757c25 100644
--- a/spec/finders/projects_finder_spec.rb
+++ b/spec/finders/projects_finder_spec.rb
@@ -234,10 +234,40 @@ RSpec.describe ProjectsFinder, :do_not_mock_admin_mode do
end
describe 'filter by without_deleted' do
- let(:params) { { without_deleted: true } }
- let!(:pending_delete_project) { create(:project, :public, pending_delete: true) }
+ let_it_be(:pending_delete_project) { create(:project, :public, pending_delete: true) }
- it { is_expected.to match_array([public_project, internal_project]) }
+ let(:params) { { without_deleted: without_deleted } }
+
+ shared_examples 'returns all projects' do
+ it { expect(subject).to include(public_project, internal_project, pending_delete_project) }
+ end
+
+ context 'when without_deleted is true' do
+ let(:without_deleted) { true }
+
+ it 'returns projects that are not pending_delete' do
+ expect(subject).not_to include(pending_delete_project)
+ expect(subject).to include(public_project, internal_project)
+ end
+ end
+
+ context 'when without_deleted is false' do
+ let(:without_deleted) { false }
+
+ it_behaves_like 'returns all projects'
+ end
+
+ context 'when without_deleted is nil' do
+ let(:without_deleted) { nil }
+
+ it_behaves_like 'returns all projects'
+ end
+
+ context 'when without_deleted is not present' do
+ let(:params) { {} }
+
+ it_behaves_like 'returns all projects'
+ end
end
describe 'filter by last_activity_after' do
diff --git a/spec/finders/user_group_notification_settings_finder_spec.rb b/spec/finders/user_group_notification_settings_finder_spec.rb
new file mode 100644
index 00000000000..453da691866
--- /dev/null
+++ b/spec/finders/user_group_notification_settings_finder_spec.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe UserGroupNotificationSettingsFinder do
+ let_it_be(:user) { create(:user) }
+
+ subject { described_class.new(user, Group.where(id: groups.map(&:id))).execute }
+
+ def attributes(&proc)
+ subject.map(&proc).uniq
+ end
+
+ context 'when the groups have no existing notification settings' do
+ context 'when the groups have no ancestors' do
+ let_it_be(:groups) { create_list(:group, 3) }
+
+ it 'will be a default Global notification setting', :aggregate_failures do
+ expect(subject.count).to eq(3)
+ expect(attributes(&:notification_email)).to eq([nil])
+ expect(attributes(&:level)).to eq(['global'])
+ end
+ end
+
+ context 'when the groups have ancestors' do
+ context 'when an ancestor has a level other than Global' do
+ let_it_be(:ancestor_a) { create(:group) }
+ let_it_be(:group_a) { create(:group, parent: ancestor_a) }
+ let_it_be(:ancestor_b) { create(:group) }
+ let_it_be(:group_b) { create(:group, parent: ancestor_b) }
+ let_it_be(:email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) }
+
+ let_it_be(:groups) { [group_a, group_b] }
+
+ before do
+ create(:notification_setting, user: user, source: ancestor_a, level: 'participating', notification_email: email.email)
+ create(:notification_setting, user: user, source: ancestor_b, level: 'participating', notification_email: email.email)
+ end
+
+ it 'has the same level set' do
+ expect(attributes(&:level)).to eq(['participating'])
+ end
+
+ it 'has the same email set' do
+ expect(attributes(&:notification_email)).to eq(['ancestor@example.com'])
+ end
+
+ it 'only returns the two queried groups' do
+ expect(subject.count).to eq(2)
+ end
+ end
+
+ context 'when an ancestor has a Global level but has an email set' do
+ let_it_be(:grand_ancestor) { create(:group) }
+ let_it_be(:ancestor) { create(:group, parent: grand_ancestor) }
+ let_it_be(:group) { create(:group, parent: ancestor) }
+ let_it_be(:ancestor_email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) }
+ let_it_be(:grand_email) { create(:email, :confirmed, email: 'grand@example.com', user: user) }
+
+ let_it_be(:groups) { [group] }
+
+ before do
+ create(:notification_setting, user: user, source: grand_ancestor, level: 'participating', notification_email: grand_email.email)
+ create(:notification_setting, user: user, source: ancestor, level: 'global', notification_email: ancestor_email.email)
+ end
+
+ it 'has the same email and level set', :aggregate_failures do
+ expect(subject.count).to eq(1)
+ expect(attributes(&:level)).to eq(['global'])
+ expect(attributes(&:notification_email)).to eq(['ancestor@example.com'])
+ end
+ end
+
+ context 'when the group has parent_id set but that does not belong to any group' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:groups) { [group] }
+
+ before do
+ # Let's set a parent_id for a group that definitely doesn't exist
+ group.update_columns(parent_id: 19283746)
+ end
+
+ it 'returns a default Global notification setting' do
+ expect(subject.count).to eq(1)
+ expect(attributes(&:level)).to eq(['global'])
+ expect(attributes(&:notification_email)).to eq([nil])
+ end
+ end
+
+ context 'when the group has a private parent' do
+ let_it_be(:ancestor) { create(:group, :private) }
+ let_it_be(:group) { create(:group, :private, parent: ancestor) }
+ let_it_be(:ancestor_email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) }
+ let_it_be(:groups) { [group] }
+
+ before do
+ group.add_reporter(user)
+ # Adding the user creates a NotificationSetting, so we remove it here
+ user.notification_settings.where(source: group).delete_all
+
+ create(:notification_setting, user: user, source: ancestor, level: 'participating', notification_email: ancestor_email.email)
+ end
+
+ it 'still inherits the notification settings' do
+ expect(subject.count).to eq(1)
+ expect(attributes(&:level)).to eq(['participating'])
+ expect(attributes(&:notification_email)).to eq([ancestor_email.email])
+ end
+ end
+
+ it 'does not cause an N+1', :aggregate_failures do
+ parent = create(:group)
+ child = create(:group, parent: parent)
+
+ control = ActiveRecord::QueryRecorder.new do
+ described_class.new(user, Group.where(id: child.id)).execute
+ end
+
+ other_parent = create(:group)
+ other_children = create_list(:group, 2, parent: other_parent)
+
+ result = nil
+
+ expect do
+ result = described_class.new(user, Group.where(id: other_children.append(child).map(&:id))).execute
+ end.not_to exceed_query_limit(control)
+
+ expect(result.count).to eq(3)
+ end
+ end
+ end
+end
diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb
index 17b36247b05..a04f5452fcd 100644
--- a/spec/finders/users_finder_spec.rb
+++ b/spec/finders/users_finder_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe UsersFinder do
it 'returns all users' do
users = described_class.new(user).execute
- expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user)
+ expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, internal_user)
end
it 'filters by username' do
@@ -54,7 +54,7 @@ RSpec.describe UsersFinder do
it 'returns no external users' do
users = described_class.new(user, external: true).execute
- expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user)
+ expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, internal_user)
end
it 'filters by created_at' do
@@ -68,19 +68,25 @@ RSpec.describe UsersFinder do
expect(users.map(&:username)).not_to include([filtered_user_before.username, filtered_user_after.username])
end
+ it 'filters by non internal users' do
+ users = described_class.new(user, non_internal: true).execute
+
+ expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user)
+ end
+
it 'does not filter by custom attributes' do
users = described_class.new(
user,
custom_attributes: { foo: 'bar' }
).execute
- expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user)
+ expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, internal_user)
end
it 'orders returned results' do
users = described_class.new(user, sort: 'id_asc').execute
- expect(users).to eq([normal_user, blocked_user, omniauth_user, user])
+ expect(users).to eq([normal_user, blocked_user, omniauth_user, internal_user, user])
end
end
@@ -96,13 +102,14 @@ RSpec.describe UsersFinder do
it 'returns all users' do
users = described_class.new(admin).execute
- expect(users).to contain_exactly(admin, normal_user, blocked_user, external_user, omniauth_user)
+ expect(users).to contain_exactly(admin, normal_user, blocked_user, external_user, omniauth_user, internal_user)
end
it 'filters by custom attributes' do
create :user_custom_attribute, user: normal_user, key: 'foo', value: 'foo'
create :user_custom_attribute, user: normal_user, key: 'bar', value: 'bar'
create :user_custom_attribute, user: blocked_user, key: 'foo', value: 'foo'
+ create :user_custom_attribute, user: internal_user, key: 'foo', value: 'foo'
users = described_class.new(
admin,
diff --git a/spec/fixtures/api/schemas/entities/discussion.json b/spec/fixtures/api/schemas/entities/discussion.json
index 21d8efe0b2b..2afabcc9195 100644
--- a/spec/fixtures/api/schemas/entities/discussion.json
+++ b/spec/fixtures/api/schemas/entities/discussion.json
@@ -60,6 +60,9 @@
"resolve_with_issue_path": { "type": "string" },
"cached_markdown_version": { "type": "integer" },
"human_access": { "type": ["string", "null"] },
+ "is_noteable_author": { "type": "boolean" },
+ "is_contributor": { "type": "boolean" },
+ "project_name": { "type": "string" },
"toggle_award_path": { "type": "string" },
"path": { "type": "string" },
"commands_changes": { "type": "object", "additionalProperties": true },
diff --git a/spec/fixtures/api/schemas/entities/github/branches.json b/spec/fixtures/api/schemas/entities/github/branches.json
new file mode 100644
index 00000000000..ee3da3704f3
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/branches.json
@@ -0,0 +1,16 @@
+{
+ "type": "array",
+ "properties" : {
+ "name": { "type": "string" },
+ "commit": {
+ "type": "object",
+ "required": ["sha", "type"],
+ "properties" : {
+ "sha": { "type": "string" },
+ "type": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/commit.json b/spec/fixtures/api/schemas/entities/github/commit.json
new file mode 100644
index 00000000000..698d933be07
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/commit.json
@@ -0,0 +1,61 @@
+{
+ "type": "object",
+ "properties" : {
+ "sha": { "type": "string" },
+ "parents": {
+ "type": "array",
+ "properties": {
+ "sha": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "author": {
+ "type": "object",
+ "required": ["login", "email"],
+ "properties" : {
+ "login": { "type": ["string", "null"] },
+ "email": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "committer": {
+ "type": "object",
+ "required": ["login", "email"],
+ "properties" : {
+ "login": { "type": ["string", "null"] },
+ "email": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "commit": {
+ "type": "object",
+ "properties": {
+ "message": { "type": "string" },
+ "author": {
+ "type": "object",
+ "required": ["name", "email", "date", "type"],
+ "properties" : {
+ "name": { "type": "string" },
+ "email": { "type": "string" },
+ "date": { "type": "date" },
+ "type": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "committer": {
+ "type": "object",
+ "required": ["name", "email", "date", "type"],
+ "properties" : {
+ "name": { "type": "string" },
+ "email": { "type": "string" },
+ "date": { "type": "date" },
+ "type": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/pull_request.json b/spec/fixtures/api/schemas/entities/github/pull_request.json
new file mode 100644
index 00000000000..6c24879b800
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/pull_request.json
@@ -0,0 +1,108 @@
+{
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "body": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "id": {
+ "type": "integer"
+ },
+ "number": {
+ "type": "integer"
+ },
+ "state": {
+ "type": "string"
+ },
+ "html_url": {
+ "type": "string"
+ },
+ "merged": {
+ "type": "boolean"
+ },
+ "merged_at": {
+ "type": [
+ "date",
+ "null"
+ ]
+ },
+ "closed_at": {
+ "type": [
+ "date",
+ "null"
+ ]
+ },
+ "updated_at": {
+ "type": "date"
+ },
+ "assignee": {
+ "$ref": "user.json"
+ },
+ "author": {
+ "$ref": "user.json"
+ },
+ "head": {
+ "type": "object",
+ "required": [
+ "label",
+ "ref",
+ "repo"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "ref": {
+ "type": "string"
+ },
+ "repo": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "repository.json"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "base": {
+ "type": "object",
+ "required": [
+ "label",
+ "ref",
+ "repo"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "ref": {
+ "type": "string"
+ },
+ "repo": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "repository.json"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+} \ No newline at end of file
diff --git a/spec/fixtures/api/schemas/entities/github/pull_requests.json b/spec/fixtures/api/schemas/entities/github/pull_requests.json
new file mode 100644
index 00000000000..4dddeb5fa20
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/pull_requests.json
@@ -0,0 +1,6 @@
+{
+ "type": "array",
+ "items": {
+ "$ref": "pull_request.json"
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/repositories.json b/spec/fixtures/api/schemas/entities/github/repositories.json
new file mode 100644
index 00000000000..26457901ef2
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/repositories.json
@@ -0,0 +1,16 @@
+{
+ "type": "array",
+ "properties" : {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "owner": {
+ "type": "object",
+ "required": ["login"],
+ "properties" : {
+ "login": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/repository.json b/spec/fixtures/api/schemas/entities/github/repository.json
new file mode 100644
index 00000000000..44d7d059140
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/repository.json
@@ -0,0 +1,16 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "owner": {
+ "type": "object",
+ "required": ["login"],
+ "properties" : {
+ "login": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/user.json b/spec/fixtures/api/schemas/entities/github/user.json
new file mode 100644
index 00000000000..3d772a0c648
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/user.json
@@ -0,0 +1,13 @@
+{
+ "type": "object",
+ "required": ["id", "login", "url", "avatar_url"],
+ "properties" : {
+ "id": { "type": "integer" },
+ "login": { "type": "string" },
+ "url": { "type": "string" },
+ "avatar_url": { "type": "string" },
+ "html_url": { "type": "string" }
+ },
+ "additionalProperties": false
+}
+
diff --git a/spec/fixtures/api/schemas/entities/group_group_link.json b/spec/fixtures/api/schemas/entities/group_group_link.json
new file mode 100644
index 00000000000..4c9aae140d2
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/group_group_link.json
@@ -0,0 +1,29 @@
+{
+ "type": "object",
+ "required": ["id", "created_at", "expires_at", "access_level"],
+ "properties": {
+ "id": { "type": "integer" },
+ "created_at": { "type": "date-time" },
+ "expires_at": { "type": ["date-time", "null"] },
+ "access_level": {
+ "type": "object",
+ "required": ["integer_value", "string_value"],
+ "properties": {
+ "integer_value": { "type": "integer" },
+ "string_value": { "type": "string" }
+ }
+ },
+ "shared_with_group": {
+ "type": "object",
+ "required": ["id", "name", "full_name", "full_path", "avatar_url", "web_url"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "full_name": { "type": "string" },
+ "full_path": { "type": "string" },
+ "avatar_url": { "type": ["string", "null"] },
+ "web_url": { "type": "string" }
+ }
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/issue_sidebar.json b/spec/fixtures/api/schemas/entities/issue_sidebar.json
index 93adb493d1b..717eb4992ea 100644
--- a/spec/fixtures/api/schemas/entities/issue_sidebar.json
+++ b/spec/fixtures/api/schemas/entities/issue_sidebar.json
@@ -42,6 +42,9 @@
"project_labels_path": { "type": "string" },
"toggle_subscription_path": { "type": "string" },
"move_issue_path": { "type": "string" },
- "projects_autocomplete_path": { "type": "string" }
+ "projects_autocomplete_path": { "type": "string" },
+ "supports_time_tracking": { "type": "boolean" },
+ "supports_milestone": { "type": "boolean" },
+ "supports_severity": { "type": "boolean" }
}
}
diff --git a/spec/fixtures/api/schemas/entities/lint_job_entity.json b/spec/fixtures/api/schemas/entities/lint_job_entity.json
new file mode 100644
index 00000000000..b85f58d4291
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/lint_job_entity.json
@@ -0,0 +1,58 @@
+{
+ "type": "object",
+ "required": [
+ "name",
+ "stage",
+ "before_script",
+ "script",
+ "after_script",
+ "tag_list",
+ "environment",
+ "when",
+ "allow_failure",
+ "only",
+ "except"
+ ],
+ "properties": {
+ "name": {
+ "type": ["string"]
+ },
+ "stage": {
+ "type": ["string"]
+ },
+ "before_script": {
+ "type": ["array"],
+ "items": { "type": "string" }
+ },
+ "script": {
+ "type": ["array"],
+ "items": { "type": "string" }
+ },
+ "after_script": {
+ "type": ["array"],
+ "items": { "type": "string" }
+ },
+ "when": {
+ "items": { "type": ["string"] }
+ },
+ "allow_failure": {
+ "type": ["boolean"]
+ },
+ "environment": {
+ "type": ["string", null]
+ },
+ "tag_list": {
+ "type": ["array"],
+ "items": { "type": "string" }
+ },
+ "only": {
+ "type": ["array", "object", null],
+ "items": { "type": ["string", "array"]}
+ },
+ "except": {
+ "type": ["array", "object", null],
+ "items": { "type": ["string", "array"]}
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/entities/lint_result_entity.json b/spec/fixtures/api/schemas/entities/lint_result_entity.json
new file mode 100644
index 00000000000..502e1dac1ac
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/lint_result_entity.json
@@ -0,0 +1,25 @@
+{
+ "type": "object",
+ "required": ["valid", "errors", "jobs", "warnings"],
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "warnings": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "valid": {
+ "type": "boolean"
+ },
+ "jobs": {
+ "type": ["array", null],
+ "items": {
+ "type": "object",
+ "$ref": "lint_job_entity.json"
+ }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json
index ac0a0314455..3c19528d71b 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_basic.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json
@@ -15,6 +15,13 @@
"$ref": "../public_api/v4/user/basic.json"
}
},
+ "reviewers": {
+ "type": ["array"],
+ "items": {
+ "type": "object",
+ "$ref": "../public_api/v4/user/basic.json"
+ }
+ },
"milestone": {
"type": [ "object", "null" ]
},
diff --git a/spec/fixtures/api/schemas/entities/merge_request_poll_cached_widget.json b/spec/fixtures/api/schemas/entities/merge_request_poll_cached_widget.json
index b40b71d2cd6..3d6b20896ce 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_poll_cached_widget.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_poll_cached_widget.json
@@ -7,8 +7,8 @@
"title": { "type": "string" },
"auto_merge_enabled": { "type": "boolean" },
"state": { "type": "string" },
- "merge_commit_sha": { "type": ["string", "null"] },
- "short_merge_commit_sha": { "type": ["string", "null"] },
+ "merged_commit_sha": { "type": ["string", "null"] },
+ "short_merged_commit_sha": { "type": ["string", "null"] },
"merge_error": { "type": ["string", "null"] },
"merge_status": { "type": "string" },
"merge_user_id": { "type": ["integer", "null"] },
@@ -40,7 +40,7 @@
"diverged_commits_count": { "type": "integer" },
"target_branch_commits_path": { "type": "string" },
"target_branch_tree_path": { "type": "string" },
- "merge_commit_path": { "type": ["string", "null"] },
+ "merged_commit_path": { "type": ["string", "null"] },
"source_branch_with_namespace_link": { "type": "string" },
"source_branch_path": { "type": "string" }
}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_poll_widget.json b/spec/fixtures/api/schemas/entities/merge_request_poll_widget.json
index 1eda0e12920..be2fe19b067 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_poll_widget.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_poll_widget.json
@@ -22,6 +22,14 @@
"only_allow_merge_if_pipeline_succeeds": { "type": "boolean" },
"has_ci": { "type": "boolean" },
"ci_status": { "type": ["string", "null"] },
+ "pipeline_coverage_delta": { "type": ["float", "null"] },
+ "builds_with_coverage": {
+ "type": ["array", "null"],
+ "items": {
+ "type": "object",
+ "required": ["name", "coverage"]
+ }
+ },
"cancel_auto_merge_path": { "type": ["string", "null"] },
"test_reports_path": { "type": ["string", "null"] },
"create_issue_to_resolve_discussions_path": { "type": ["string", "null"] },
diff --git a/spec/fixtures/api/schemas/entities/merge_request_sidebar.json b/spec/fixtures/api/schemas/entities/merge_request_sidebar.json
index 9945de8a856..633203aed28 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_sidebar.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_sidebar.json
@@ -51,6 +51,8 @@
"project_labels_path": { "type": "string" },
"toggle_subscription_path": { "type": "string" },
"move_issue_path": { "type": "string" },
- "projects_autocomplete_path": { "type": "string" }
+ "projects_autocomplete_path": { "type": "string" },
+ "supports_time_tracking": { "type": "boolean" },
+ "supports_milestone": { "type": "boolean" }
}
}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_sidebar_extras.json b/spec/fixtures/api/schemas/entities/merge_request_sidebar_extras.json
index 11076ec73de..c54ae551d6a 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_sidebar_extras.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_sidebar_extras.json
@@ -17,6 +17,10 @@
"assignees": {
"type": "array",
"items": { "$ref": "../public_api/v4/user/basic.json" }
+ },
+ "reviewers": {
+ "type": "array",
+ "items": { "$ref": "../public_api/v4/user/basic.json" }
}
},
"additionalProperties": false
diff --git a/spec/fixtures/api/schemas/group_group_links.json b/spec/fixtures/api/schemas/group_group_links.json
new file mode 100644
index 00000000000..f8b4e7f035b
--- /dev/null
+++ b/spec/fixtures/api/schemas/group_group_links.json
@@ -0,0 +1,6 @@
+{
+ "type": "array",
+ "items": {
+ "$ref": "entities/group_group_link.json"
+ }
+}
diff --git a/spec/fixtures/api/schemas/group_member.json b/spec/fixtures/api/schemas/group_member.json
new file mode 100644
index 00000000000..035c862d229
--- /dev/null
+++ b/spec/fixtures/api/schemas/group_member.json
@@ -0,0 +1,78 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "created_at",
+ "expires_at",
+ "access_level",
+ "requested_at",
+ "source",
+ "can_update",
+ "can_remove",
+ "can_override"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "created_at": { "type": "date-time" },
+ "expires_at": { "type": ["date-time", "null"] },
+ "requested_at": { "type": ["date-time", "null"] },
+ "can_update": { "type": "boolean" },
+ "can_remove": { "type": "boolean" },
+ "can_override": { "type": "boolean" },
+ "access_level": {
+ "type": "object",
+ "required": ["integer_value", "string_value"],
+ "properties": {
+ "integer_value": { "type": "integer" },
+ "string_value": { "type": "string" }
+ }
+ },
+ "source": {
+ "type": "object",
+ "required": ["id", "name", "web_url"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "web_url": { "type": "string" }
+ }
+ },
+ "created_by": {
+ "type": "object",
+ "required": ["name", "web_url"],
+ "properties": {
+ "name": { "type": "string" },
+ "web_url": { "type": "string" }
+ }
+ },
+ "user": {
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "username",
+ "avatar_url",
+ "web_url",
+ "blocked",
+ "two_factor_enabled"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "username": { "type": "string" },
+ "avatar_url": { "type": ["string", "null"] },
+ "web_url": { "type": "string" },
+ "blocked": { "type": "boolean" },
+ "two_factor_enabled": { "type": "boolean" }
+ }
+ },
+ "invite": {
+ "type": "object",
+ "required": ["email", "avatar_url", "can_resend"],
+ "properties": {
+ "email": { "type": "string" },
+ "avatar_url": { "type": "string" },
+ "can_resend": { "type": "boolean" }
+ }
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/group_members.json b/spec/fixtures/api/schemas/group_members.json
new file mode 100644
index 00000000000..6268c7ef4d8
--- /dev/null
+++ b/spec/fixtures/api/schemas/group_members.json
@@ -0,0 +1,6 @@
+{
+ "type": "array",
+ "items": {
+ "$ref": "group_member.json"
+ }
+}
diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json
index 77de9ae4f9f..84c39ad65e9 100644
--- a/spec/fixtures/api/schemas/issue.json
+++ b/spec/fixtures/api/schemas/issue.json
@@ -12,7 +12,7 @@
"title": { "type": "string" },
"confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] },
- "relative_position": { "type": "integer" },
+ "relative_position": { "type": ["integer", "null"] },
"time_estimate": { "type": "integer" },
"issue_sidebar_endpoint": { "type": "string" },
"toggle_subscription_endpoint": { "type": "string" },
diff --git a/spec/fixtures/api/schemas/jira_connect/author.json b/spec/fixtures/api/schemas/jira_connect/author.json
new file mode 100644
index 00000000000..bd2cff96d99
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/author.json
@@ -0,0 +1,12 @@
+{
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "email": { "type": "string" },
+ "username": { "type": "string" },
+ "url": { "type": "uri" },
+ "avatar": { "type": "uri" }
+ },
+ "required": [ "name", "email" ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/branch.json b/spec/fixtures/api/schemas/jira_connect/branch.json
new file mode 100644
index 00000000000..c397d88fa91
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/branch.json
@@ -0,0 +1,19 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "issueKeys": { "type": "array" },
+ "name": { "type": "string" },
+ "lastCommit": {
+ "$ref": "./commit.json"
+ },
+ "url": { "type": "uri" },
+ "createPullRequestUrl": { "type": "uri" },
+ "updateSequenceId": { "type": "integer" }
+ },
+ "required": [
+ "id", "issueKeys", "name", "lastCommit",
+ "url", "createPullRequestUrl", "updateSequenceId"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/commit.json b/spec/fixtures/api/schemas/jira_connect/commit.json
new file mode 100644
index 00000000000..794cf9ef365
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/commit.json
@@ -0,0 +1,29 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "issueKeys": { "type": "array" },
+ "hash": { "type": "string" },
+ "displayId": { "type": "string" },
+ "message": { "type": "string" },
+ "flags": { "type": "array" },
+ "author": {
+ "$ref": "./author.json"
+ },
+ "fileCount": { "type": "integer" },
+ "files": {
+ "type": "array",
+ "items": {
+ "$ref": "./file.json"
+ }
+ },
+ "authorTimestamp": { "type": "timestamp" },
+ "url": { "type": "uri" },
+ "updateSequenceId": { "type": "integer" }
+ },
+ "required": [
+ "id", "issueKeys", "hash", "displayId", "message", "flags", "author",
+ "fileCount", "files", "authorTimestamp", "url", "updateSequenceId"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/file.json b/spec/fixtures/api/schemas/jira_connect/file.json
new file mode 100644
index 00000000000..34718991237
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/file.json
@@ -0,0 +1,14 @@
+{
+ "type": "object",
+ "properties": {
+ "path": { "type": "string" },
+ "changeType": { "type": "string" },
+ "linesAdded": { "type": "integer" },
+ "linesRemoved": { "type": "integer" },
+ "url": { "type": "uri" }
+ },
+ "required": [
+ "path", "changeType", "linesAdded", "linesRemoved", "url"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/pull_request.json b/spec/fixtures/api/schemas/jira_connect/pull_request.json
new file mode 100644
index 00000000000..56ce6faf498
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/pull_request.json
@@ -0,0 +1,26 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "issueKeys": { "type": "array" },
+ "displayId": { "type": "string" },
+ "title": { "type": "string" },
+ "author": {
+ "$ref": "./author.json"
+ },
+ "commentCount": { "type": "integer" },
+ "sourceBranch": { "type": "string" },
+ "destinationBranch": { "type": "string" },
+ "lastUpdate": { "type": "timestamp" },
+ "status": { "type": "string" },
+ "sourceBranchUrl": { "type": "uri" },
+ "url": { "type": "uri" },
+ "updateSequenceId": { "type": "integer" }
+ },
+ "required": [
+ "id", "issueKeys", "displayId", "title", "author", "commentCount",
+ "sourceBranch", "destinationBranch", "lastUpdate", "status",
+ "sourceBranchUrl", "url", "updateSequenceId"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/repository.json b/spec/fixtures/api/schemas/jira_connect/repository.json
new file mode 100644
index 00000000000..9e81d77bc6a
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/repository.json
@@ -0,0 +1,34 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "name": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "url": { "type": "uri" },
+ "avatar": { "type": "uri" },
+ "commits": {
+ "type": "array",
+ "items": {
+ "$ref": "./commit.json"
+ }
+ },
+ "branches": {
+ "type": "array",
+ "items": {
+ "$ref": "./branch.json"
+ }
+ },
+ "pullRequests": {
+ "type": "array",
+ "items": {
+ "$ref": "./pull_request.json"
+ }
+ },
+ "updateSequenceId": { "type": "integer" }
+ },
+ "required": [
+ "id", "name", "description", "url", "avatar",
+ "commits", "branches", "pullRequests", "updateSequenceId"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/issue_link.json b/spec/fixtures/api/schemas/public_api/v4/issue_link.json
new file mode 100644
index 00000000000..000e8485aca
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/issue_link.json
@@ -0,0 +1,20 @@
+{
+ "type": "object",
+ "properties" : {
+ "source_issue": {
+ "allOf": [
+ { "$ref": "../../../../../../spec/fixtures/api/schemas/public_api/v4/issue.json" }
+ ]
+ },
+ "target_issue": {
+ "allOf": [
+ { "$ref": "../../../../../../spec/fixtures/api/schemas/public_api/v4/issue.json" }
+ ]
+ },
+ "link_type": {
+ "type": "string",
+ "enum": ["relates_to", "blocks", "is_blocked_by"]
+ }
+ },
+ "required" : [ "source_issue", "target_issue", "link_type" ]
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/issue_links.json b/spec/fixtures/api/schemas/public_api/v4/issue_links.json
new file mode 100644
index 00000000000..d254615dd58
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/issue_links.json
@@ -0,0 +1,9 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties" : {
+ "$ref": "./issue_link.json"
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/milestone.json b/spec/fixtures/api/schemas/public_api/v4/milestone.json
index 6ca2e88ae91..c8c6a7b6ae1 100644
--- a/spec/fixtures/api/schemas/public_api/v4/milestone.json
+++ b/spec/fixtures/api/schemas/public_api/v4/milestone.json
@@ -12,11 +12,13 @@
"updated_at": { "type": "date" },
"start_date": { "type": "date" },
"due_date": { "type": "date" },
+ "expired": { "type": ["boolean", "null"] },
"web_url": { "type": "string" }
},
"required": [
"id", "iid", "title", "description", "state",
- "state", "created_at", "updated_at", "start_date", "due_date"
+ "state", "created_at", "updated_at", "start_date",
+ "due_date", "expired"
],
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/milestone_with_stats.json b/spec/fixtures/api/schemas/public_api/v4/milestone_with_stats.json
index e2475545ee9..f008ed7d55f 100644
--- a/spec/fixtures/api/schemas/public_api/v4/milestone_with_stats.json
+++ b/spec/fixtures/api/schemas/public_api/v4/milestone_with_stats.json
@@ -12,6 +12,7 @@
"updated_at": { "type": "date" },
"start_date": { "type": "date" },
"due_date": { "type": "date" },
+ "expired": { "type": ["boolean", "null"] },
"web_url": { "type": "string" },
"issue_stats": {
"required": ["total", "closed"],
@@ -24,7 +25,8 @@
},
"required": [
"id", "iid", "title", "description", "state",
- "state", "created_at", "updated_at", "start_date", "due_date", "issue_stats"
+ "state", "created_at", "updated_at", "start_date",
+ "due_date", "expired", "issue_stats"
],
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/packages/collection_package.json b/spec/fixtures/api/schemas/public_api/v4/packages/collection_package.json
new file mode 100644
index 00000000000..109b0e59635
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/packages/collection_package.json
@@ -0,0 +1,34 @@
+{
+ "type": "object",
+ "required": [
+ "name",
+ "version",
+ "package_type",
+ "_links"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "package_type": {
+ "type": "string"
+ },
+ "_links": {
+ "type": "object",
+ "required": [
+ "web_path"
+ ],
+ "properties": {
+ "details": {
+ "type": "string"
+ }
+ }
+ },
+ "created_at": {
+ "type": "string"
+ }
+ }
+} \ No newline at end of file
diff --git a/spec/fixtures/api/schemas/public_api/v4/packages/packages.json b/spec/fixtures/api/schemas/public_api/v4/packages/packages.json
index 66364da4fdb..a03a49db4f9 100644
--- a/spec/fixtures/api/schemas/public_api/v4/packages/packages.json
+++ b/spec/fixtures/api/schemas/public_api/v4/packages/packages.json
@@ -1,4 +1,4 @@
{
"type": "array",
- "items": { "$ref": "./package.json" }
+ "items": { "$ref": "./collection_package.json" }
}
diff --git a/spec/fixtures/emails/x_envelope_to_header.eml b/spec/fixtures/emails/x_envelope_to_header.eml
new file mode 100644
index 00000000000..28e3d71535a
--- /dev/null
+++ b/spec/fixtures/emails/x_envelope_to_header.eml
@@ -0,0 +1,32 @@
+Return-Path: <jake@example.com>
+Received: from myserver.example.com ([unix socket]) by myserver (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
+Received: from mail.example.com (mail.example.com [IPv6:2607:f8b0:4001:c03::234]) by myserver.example.com (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@example.com>; Thu, 13 Jun 2013 17:03:50 -0400
+Received: by myserver.example.com with SMTP id f4so21977375iea.25 for <incoming+gitlabhq/gitlabhq@appmail.example.com>; Thu, 13 Jun 2013 14:03:48 -0700
+Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
+From: "jake@example.com" <jake@example.com>
+To: "support@example.com" <support@example.com>
+Subject: Insert hilarious subject line here
+Date: Tue, 26 Nov 2019 14:22:41 +0000
+Message-ID: <7e2296f83dbf4de388cbf5f56f52c11f@EXDAG29-1.EXCHANGE.INT>
+Accept-Language: de-DE, en-US
+Content-Language: de-DE
+X-MS-Has-Attach:
+X-MS-TNEF-Correlator:
+x-ms-exchange-transport-fromentityheader: Hosted
+x-originating-ip: [62.96.54.178]
+Content-Type: multipart/alternative;
+ boundary="_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_"
+MIME-Version: 1.0
+X-Envelope-To: incoming+gitlabhq/gitlabhq+auth_token@appmail.example.com
+
+--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_
+Content-Type: text/plain; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+
+--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_
+Content-Type: text/html; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+Look, a message with some alternate headers! We should really support them.
diff --git a/spec/fixtures/gitlab/database/structure_example_cleaned.sql b/spec/fixtures/gitlab/database/structure_example_cleaned.sql
index 42eed974e64..dc112da7037 100644
--- a/spec/fixtures/gitlab/database/structure_example_cleaned.sql
+++ b/spec/fixtures/gitlab/database/structure_example_cleaned.sql
@@ -1,8 +1,6 @@
-SET search_path=public;
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
-CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
-
-CREATE TABLE public.abuse_reports (
+CREATE TABLE abuse_reports (
id integer NOT NULL,
reporter_id integer,
user_id integer,
@@ -13,20 +11,18 @@ CREATE TABLE public.abuse_reports (
cached_markdown_version integer
);
-CREATE SEQUENCE public.abuse_reports_id_seq
+CREATE SEQUENCE abuse_reports_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
-ALTER TABLE ONLY public.abuse_reports ALTER COLUMN id SET DEFAULT nextval('public.abuse_reports_id_seq'::regclass);
+ALTER TABLE ONLY abuse_reports ALTER COLUMN id SET DEFAULT nextval('abuse_reports_id_seq'::regclass);
-ALTER TABLE ONLY public.abuse_reports
+ALTER TABLE ONLY abuse_reports
ADD CONSTRAINT abuse_reports_pkey PRIMARY KEY (id);
-CREATE INDEX index_abuse_reports_on_user_id ON public.abuse_reports USING btree (user_id);
-
--- schema_migrations.version information is no longer stored in this file,
+CREATE INDEX index_abuse_reports_on_user_id ON abuse_reports USING btree (user_id);-- schema_migrations.version information is no longer stored in this file,
-- but instead tracked in the db/schema_migrations directory
-- see https://gitlab.com/gitlab-org/gitlab/-/issues/218590 for details
diff --git a/spec/fixtures/importers/bitbucket_server/activities.json b/spec/fixtures/importers/bitbucket_server/activities.json
index 09adfca9f31..ddcb94b8f58 100644
--- a/spec/fixtures/importers/bitbucket_server/activities.json
+++ b/spec/fixtures/importers/bitbucket_server/activities.json
@@ -20,7 +20,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [
@@ -38,7 +39,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [
@@ -56,7 +58,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [],
@@ -85,7 +88,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"createdDate": 1530164016725,
@@ -115,7 +119,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"createdDate": 1530164026000,
@@ -147,7 +152,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [],
@@ -194,7 +200,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [],
@@ -363,7 +370,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
}
},
@@ -383,7 +391,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [],
@@ -543,7 +552,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
}
},
@@ -563,7 +573,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [
@@ -581,7 +592,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [
@@ -599,7 +611,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [],
@@ -789,7 +802,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
}
},
@@ -809,7 +823,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [],
@@ -843,7 +858,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
}
},
@@ -863,7 +879,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"authorTimestamp": 1529727872000,
@@ -880,7 +897,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"committerTimestamp": 1529727872000,
@@ -951,7 +969,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
}
},
@@ -971,7 +990,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [
@@ -989,7 +1009,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [],
@@ -1038,7 +1059,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
}
},
@@ -1058,7 +1080,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
},
"comments": [],
@@ -1092,7 +1115,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
}
},
@@ -1113,7 +1137,8 @@
]
},
"name": "root",
- "slug": "root",
+ "slug": "slug",
+ "username": "username",
"type": "NORMAL"
}
}
diff --git a/spec/fixtures/importers/bitbucket_server/pull_request.json b/spec/fixtures/importers/bitbucket_server/pull_request.json
index 6c7fcf3b04c..77f7eb330f5 100644
--- a/spec/fixtures/importers/bitbucket_server/pull_request.json
+++ b/spec/fixtures/importers/bitbucket_server/pull_request.json
@@ -5,8 +5,9 @@
"status":"UNAPPROVED",
"user":{
"active":true,
- "displayName":"root",
+ "displayName":"displayName",
"emailAddress":"joe.montana@49ers.com",
+ "username": "username",
"id":1,
"links":{
"self":[
@@ -16,7 +17,7 @@
]
},
"name":"root",
- "slug":"root",
+ "slug":"slug",
"type":"NORMAL"
}
},
diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/broken_yml_syntax.yml b/spec/fixtures/lib/gitlab/metrics/dashboard/broken_yml_syntax.yml
new file mode 100644
index 00000000000..d551ad2dcdb
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/metrics/dashboard/broken_yml_syntax.yml
@@ -0,0 +1,13 @@
+dashboard: 'Test Dashboard'
+ panel_groups:
+ - group: Group B
+ panels:
+ - title: "Super Chart B"
+ type: "area-chart"
+ y_label: "y_label"
+ metrics:
+ - id: metric_b
+ query_range: 'query'
+ unit: unit
+ label: Legend Label
+- group: Group A
diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb
index 59795c835a2..aff4b1aae23 100644
--- a/spec/fixtures/markdown.md.erb
+++ b/spec/fixtures/markdown.md.erb
@@ -245,6 +245,15 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
- Group milestone by name in quotes: <%= group_milestone.to_reference(format: :name) %>
- Group milestone by URL is ignore: <%= urls.milestone_url(group_milestone) %>
+##### AlertReferenceFilter
+- Alert: <%= alert.to_reference %>
+- Alert in another project: <%= xalert.to_reference(project) %>
+- Ignored in code: `<%= alert.to_reference %>`
+- Ignored in links: [Link to <%= alert.to_reference %>](#alert-link)
+- Alert by URL: <%= alert.details_url %>
+- Link to alert by reference: [Alert](<%= alert.to_reference %>)
+- Link to alert by URL: [Alert](<%= alert.details_url %>)
+
### Task Lists
- [ ] Incomplete task 1
diff --git a/spec/fixtures/pipeline_artifacts/code_coverage_with_multiple_files.json b/spec/fixtures/pipeline_artifacts/code_coverage_with_multiple_files.json
new file mode 100644
index 00000000000..160413d73da
--- /dev/null
+++ b/spec/fixtures/pipeline_artifacts/code_coverage_with_multiple_files.json
@@ -0,0 +1,14 @@
+{
+ "files": {
+ "file_a.rb": {
+ "1": 1,
+ "2": 1,
+ "3": 1
+ },
+ "file_b.rb": {
+ "1": 0,
+ "2": 0,
+ "3": 0
+ }
+ }
+}
diff --git a/spec/fixtures/unescaped_chars.po b/spec/fixtures/unescaped_chars.po
index fbafe523fb3..3527c3e0888 100644
--- a/spec/fixtures/unescaped_chars.po
+++ b/spec/fixtures/unescaped_chars.po
@@ -7,15 +7,15 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
-"PO-Revision-Date: 2017-07-13 12:10-0500\n"
+"PO-Revision-Date: 2020-09-02 06:14+0000\n"
"Language-Team: Spanish\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
-"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
+"Last-Translator: Takuya Noguchi <takninnovationresearch@gmail.com>\n"
"X-Generator: Poedit 2.0.2\n"
-msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
-msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?"
+msgid "You are going to transfer %{project_name_with_namespace} to another namespace. Are you ABSOLUTELY sure?"
+msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一个名称空间。真的「100%確定」要這麼做嗎?"
diff --git a/spec/fixtures/valid.po b/spec/fixtures/valid.po
index f1d62296709..c9b68d16153 100644
--- a/spec/fixtures/valid.po
+++ b/spec/fixtures/valid.po
@@ -1071,7 +1071,7 @@ msgstr "Va a eliminar %{project_name_with_namespace}. ¡El proyecto eliminado NO
msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
msgstr "Vas a eliminar el enlace de la bifurcación con el proyecto original %{forked_from_project}. ¿Estás TOTALMENTE seguro?"
-msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgid "You are going to transfer %{project_name_with_namespace} to another namespace. Are you ABSOLUTELY sure?"
msgstr "Vas a transferir %{project_name_with_namespace} a otro propietario. ¿Estás TOTALMENTE seguro?"
msgid "You can only add files when you are on a branch"
diff --git a/spec/fixtures/whats_new/01.yml b/spec/fixtures/whats_new/01.yml
new file mode 100644
index 00000000000..06db95be44f
--- /dev/null
+++ b/spec/fixtures/whats_new/01.yml
@@ -0,0 +1,2 @@
+---
+- title: It's gonna be a bright
diff --git a/spec/fixtures/whats_new/02.yml b/spec/fixtures/whats_new/02.yml
new file mode 100644
index 00000000000..91b0bd7036e
--- /dev/null
+++ b/spec/fixtures/whats_new/02.yml
@@ -0,0 +1,2 @@
+---
+- title: bright
diff --git a/spec/fixtures/whats_new/05.yml b/spec/fixtures/whats_new/05.yml
new file mode 100644
index 00000000000..5b8939a2bc6
--- /dev/null
+++ b/spec/fixtures/whats_new/05.yml
@@ -0,0 +1,2 @@
+---
+- title: bright and sunshinin' day
diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js
index ea36f1dabaf..237f8b408f5 100644
--- a/spec/frontend/__mocks__/@gitlab/ui.js
+++ b/spec/frontend/__mocks__/@gitlab/ui.js
@@ -18,8 +18,16 @@ jest.mock('@gitlab/ui/dist/directives/tooltip.js', () => ({
}));
jest.mock('@gitlab/ui/dist/components/base/tooltip/tooltip.js', () => ({
+ props: ['target', 'id', 'triggers', 'placement', 'container', 'boundary', 'disabled'],
render(h) {
- return h('div', this.$attrs, this.$slots.default);
+ return h(
+ 'div',
+ {
+ class: 'gl-tooltip',
+ ...this.$attrs,
+ },
+ this.$slots.default,
+ );
},
}));
diff --git a/spec/frontend/__mocks__/lodash/debounce.js b/spec/frontend/__mocks__/lodash/debounce.js
index 97fdb39097a..e8b61c80147 100644
--- a/spec/frontend/__mocks__/lodash/debounce.js
+++ b/spec/frontend/__mocks__/lodash/debounce.js
@@ -8,4 +8,15 @@
// [2]: https://gitlab.com/gitlab-org/gitlab/-/issues/213378
// Further reference: https://github.com/facebook/jest/issues/3465
-export default fn => fn;
+export default fn => {
+ const debouncedFn = jest.fn().mockImplementation(fn);
+ debouncedFn.cancel = jest.fn();
+ debouncedFn.flush = jest.fn().mockImplementation(() => {
+ const errorMessage =
+ "The .flush() method returned by lodash.debounce is not yet implemented/mocked by the mock in 'spec/frontend/__mocks__/lodash/debounce.js'.";
+
+ throw new Error(errorMessage);
+ });
+
+ return debouncedFn;
+};
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 6904e34db5d..1a3b151afa0 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
@@ -78,7 +78,7 @@ describe('AddContextCommitsModal', () => {
findSearch().vm.$emit('input', searchText);
expect(searchCommits).not.toBeCalled();
jest.advanceTimersByTime(500);
- expect(searchCommits).toHaveBeenCalledWith(expect.anything(), searchText, undefined);
+ expect(searchCommits).toHaveBeenCalledWith(expect.anything(), searchText);
});
it('disabled ok button when no row is selected', () => {
@@ -119,18 +119,17 @@ describe('AddContextCommitsModal', () => {
wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }];
findModal().vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
- expect(createContextCommits).toHaveBeenCalledWith(
- expect.anything(),
- { commits: [{ ...commit, isSelected: true }], forceReload: true },
- undefined,
- );
+ expect(createContextCommits).toHaveBeenCalledWith(expect.anything(), {
+ commits: [{ ...commit, isSelected: true }],
+ forceReload: true,
+ });
});
});
it('"removeContextCommits" when only added commits are to be removed ', () => {
wrapper.vm.$store.state.toRemoveCommits = [commit.short_id];
findModal().vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
- expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), true, undefined);
+ expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), true);
});
});
it('"createContextCommits" and "removeContextCommits" when new commits are to be added and old commits are to be removed', () => {
@@ -138,12 +137,10 @@ describe('AddContextCommitsModal', () => {
wrapper.vm.$store.state.toRemoveCommits = [commit.short_id];
findModal().vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
- expect(createContextCommits).toHaveBeenCalledWith(
- expect.anything(),
- { commits: [{ ...commit, isSelected: true }] },
- undefined,
- );
- expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), undefined, undefined);
+ expect(createContextCommits).toHaveBeenCalledWith(expect.anything(), {
+ commits: [{ ...commit, isSelected: true }],
+ });
+ expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), undefined);
});
});
});
@@ -156,7 +153,7 @@ describe('AddContextCommitsModal', () => {
});
it('"resetModalState" to reset all the modal state', () => {
findModal().vm.$emit('cancel');
- expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined, undefined);
+ expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined);
});
});
@@ -168,7 +165,7 @@ describe('AddContextCommitsModal', () => {
});
it('"resetModalState" to reset all the modal state', () => {
findModal().vm.$emit('close');
- expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined, undefined);
+ expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined);
});
});
});
diff --git a/spec/frontend/ajax_loading_spinner_spec.js b/spec/frontend/ajax_loading_spinner_spec.js
deleted file mode 100644
index 8ed2ee49ff8..00000000000
--- a/spec/frontend/ajax_loading_spinner_spec.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import $ from 'jquery';
-import AjaxLoadingSpinner from '~/ajax_loading_spinner';
-
-describe('Ajax Loading Spinner', () => {
- const fixtureTemplate = 'static/ajax_loading_spinner.html';
- preloadFixtures(fixtureTemplate);
-
- beforeEach(() => {
- loadFixtures(fixtureTemplate);
- AjaxLoadingSpinner.init();
- });
-
- it('change current icon with spinner icon and disable link while waiting ajax response', done => {
- jest.spyOn($, 'ajax').mockImplementation(req => {
- const xhr = new XMLHttpRequest();
- const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner');
- const icon = ajaxLoadingSpinner.querySelector('i');
-
- req.beforeSend(xhr, { dataType: 'text/html' });
-
- expect(icon).not.toHaveClass('fa-trash-o');
- expect(icon).toHaveClass('fa-spinner');
- expect(icon).toHaveClass('fa-spin');
- expect(icon.dataset.icon).toEqual('fa-trash-o');
- expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual('');
-
- req.complete({});
-
- done();
- const deferred = $.Deferred();
- return deferred.promise();
- });
- document.querySelector('.js-ajax-loading-spinner').click();
- });
-
- it('use original icon again and enabled the link after complete the ajax request', done => {
- jest.spyOn($, 'ajax').mockImplementation(req => {
- const xhr = new XMLHttpRequest();
- const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner');
-
- req.beforeSend(xhr, { dataType: 'text/html' });
- req.complete({});
-
- const icon = ajaxLoadingSpinner.querySelector('i');
-
- expect(icon).toHaveClass('fa-trash-o');
- expect(icon).not.toHaveClass('fa-spinner');
- expect(icon).not.toHaveClass('fa-spin');
- expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual(null);
-
- done();
- const deferred = $.Deferred();
- return deferred.promise();
- });
- document.querySelector('.js-ajax-loading-spinner').click();
- });
-});
diff --git a/spec/frontend/alert_handler_spec.js b/spec/frontend/alert_handler_spec.js
new file mode 100644
index 00000000000..ba2f4f24aa5
--- /dev/null
+++ b/spec/frontend/alert_handler_spec.js
@@ -0,0 +1,46 @@
+import { setHTMLFixture } from 'helpers/fixtures';
+import initAlertHandler from '~/alert_handler';
+
+describe('Alert Handler', () => {
+ const ALERT_SELECTOR = 'gl-alert';
+ const CLOSE_SELECTOR = 'gl-alert-dismiss';
+ const ALERT_HTML = `<div class="${ALERT_SELECTOR}"><button class="${CLOSE_SELECTOR}">Dismiss</button></div>`;
+
+ const findFirstAlert = () => document.querySelector(`.${ALERT_SELECTOR}`);
+ const findAllAlerts = () => document.querySelectorAll(`.${ALERT_SELECTOR}`);
+ const findFirstCloseButton = () => document.querySelector(`.${CLOSE_SELECTOR}`);
+
+ describe('initAlertHandler', () => {
+ describe('with one alert', () => {
+ beforeEach(() => {
+ setHTMLFixture(ALERT_HTML);
+ initAlertHandler();
+ });
+
+ it('should render the alert', () => {
+ expect(findFirstAlert()).toExist();
+ });
+
+ it('should dismiss the alert on click', () => {
+ findFirstCloseButton().click();
+ expect(findFirstAlert()).not.toExist();
+ });
+ });
+
+ describe('with two alerts', () => {
+ beforeEach(() => {
+ setHTMLFixture(ALERT_HTML + ALERT_HTML);
+ initAlertHandler();
+ });
+
+ it('should render two alerts', () => {
+ expect(findAllAlerts()).toHaveLength(2);
+ });
+
+ it('should dismiss only one alert on click', () => {
+ findFirstCloseButton().click();
+ expect(findAllAlerts()).toHaveLength(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/alert_management/components/alert_details_spec.js b/spec/frontend/alert_management/components/alert_details_spec.js
index 2c4ed100a56..8aa26dbca3b 100644
--- a/spec/frontend/alert_management/components/alert_details_spec.js
+++ b/spec/frontend/alert_management/components/alert_details_spec.js
@@ -1,7 +1,8 @@
import { mount, shallowMount } from '@vue/test-utils';
-import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import AlertDetails from '~/alert_management/components/alert_details.vue';
import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
@@ -22,8 +23,6 @@ describe('AlertDetails', () => {
const projectId = '1';
const $router = { replace: jest.fn() };
- const findDetailsTable = () => wrapper.find(GlTable);
-
function mountComponent({ data, loading = false, mountMethod = shallowMount, stubs = {} } = {}) {
wrapper = mountMethod(AlertDetails, {
provide: {
@@ -66,6 +65,7 @@ describe('AlertDetails', () => {
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findViewIncidentBtn = () => wrapper.find('[data-testid="viewIncidentBtn"]');
const findIncidentCreationAlert = () => wrapper.find('[data-testid="incidentCreationError"]');
+ const findDetailsTable = () => wrapper.find(AlertDetailsTable);
describe('Alert details', () => {
describe('when alert is null', () => {
@@ -87,8 +87,8 @@ describe('AlertDetails', () => {
expect(wrapper.find('[data-testid="overview"]').exists()).toBe(true);
});
- it('renders a tab with full alert information', () => {
- expect(wrapper.find('[data-testid="fullDetails"]').exists()).toBe(true);
+ it('renders a tab with an activity feed', () => {
+ expect(wrapper.find('[data-testid="activity"]').exists()).toBe(true);
});
it('renders severity', () => {
@@ -198,7 +198,6 @@ describe('AlertDetails', () => {
mountComponent({ data: { alert: mockAlert } });
});
it('should display a table of raw alert details data', () => {
- wrapper.find('[data-testid="fullDetails"]').trigger('click');
expect(findDetailsTable().exists()).toBe(true);
});
});
@@ -234,7 +233,7 @@ describe('AlertDetails', () => {
describe('header', () => {
const findHeader = () => wrapper.find('[data-testid="alert-header"]');
- const stubs = { TimeAgoTooltip: '<span>now</span>' };
+ const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } };
describe('individual header fields', () => {
describe.each`
@@ -268,8 +267,8 @@ describe('AlertDetails', () => {
it.each`
index | tabId
${0} | ${'overview'}
- ${1} | ${'fullDetails'}
- ${2} | ${'metrics'}
+ ${1} | ${'metrics'}
+ ${2} | ${'activity'}
`('will navigate to the correct tab via $tabId', ({ index, tabId }) => {
wrapper.setData({ currentTabIndex: index });
expect($router.replace).toHaveBeenCalledWith({ name: 'tab', params: { tabId } });
diff --git a/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js b/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js
index 2814b5ce357..ea7b4584a63 100644
--- a/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils';
import SidebarTodo from '~/alert_management/components/sidebar/sidebar_todo.vue';
-import AlertMarkTodo from '~/alert_management/graphql/mutations/alert_todo_create.mutation.graphql';
+import createAlertTodoMutation from '~/alert_management/graphql/mutations/alert_todo_create.mutation.graphql';
+import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql';
import mockAlerts from '../mocks/alerts.json';
const mockAlert = mockAlerts[0];
@@ -61,14 +62,14 @@ describe('Alert Details Sidebar To Do', () => {
expect(findToDoButton().text()).toBe('Add a To-Do');
});
- it('calls `$apollo.mutate` with `AlertMarkTodo` mutation and variables containing `iid`, `todoEvent`, & `projectPath`', async () => {
+ it('calls `$apollo.mutate` with `createAlertTodoMutation` mutation and variables containing `iid`, `todoEvent`, & `projectPath`', async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
findToDoButton().trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: AlertMarkTodo,
+ mutation: createAlertTodoMutation,
variables: {
iid: '1527542',
projectPath: 'projectPath',
@@ -76,6 +77,7 @@ describe('Alert Details Sidebar To Do', () => {
});
});
});
+
describe('removing a todo', () => {
beforeEach(() => {
mountComponent({
@@ -91,12 +93,19 @@ describe('Alert Details Sidebar To Do', () => {
expect(findToDoButton().text()).toBe('Mark as done');
});
- it('calls `$apollo.mutate` with `AlertMarkTodoDone` mutation and variables containing `id`', async () => {
+ it('calls `$apollo.mutate` with `todoMarkDoneMutation` mutation and variables containing `id`', async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
findToDoButton().trigger('click');
await wrapper.vm.$nextTick();
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: todoMarkDoneMutation,
+ update: expect.anything(),
+ variables: {
+ id: '1234',
+ },
+ });
});
});
});
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 5dd0d9dc1ba..bcad415eb19 100644
--- a/spec/frontend/alert_management/components/alert_management_table_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_table_spec.js
@@ -11,6 +11,7 @@ import {
GlBadge,
GlPagination,
GlSearchBoxByType,
+ GlAvatar,
} from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -39,19 +40,21 @@ describe('AlertManagementTable', () => {
const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
const findDateFields = () => wrapper.findAll(TimeAgo);
const findFirstStatusOption = () => findStatusDropdown().find(GlDeprecatedDropdownItem);
- const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]');
- const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]');
- const findSeverityColumnHeader = () => wrapper.findAll('th').at(0);
const findPagination = () => wrapper.find(GlPagination);
const findSearch = () => wrapper.find(GlSearchBoxByType);
+ const findSeverityColumnHeader = () =>
+ wrapper.find('[data-testid="alert-management-severity-sort"]');
+ const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0);
+ const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]');
+ const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]');
const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]');
const findAlertError = () => wrapper.find('[data-testid="alert-error"]');
const alertsCount = {
- open: 14,
- triggered: 10,
- acknowledged: 6,
- resolved: 1,
- all: 16,
+ open: 24,
+ triggered: 20,
+ acknowledged: 16,
+ resolved: 11,
+ all: 26,
};
const selectFirstStatusOption = () => {
findFirstStatusOption().vm.$emit('click');
@@ -92,13 +95,10 @@ describe('AlertManagementTable', () => {
});
}
- beforeEach(() => {
- mountComponent({ data: { alerts: mockAlerts, alertsCount } });
- });
-
afterEach(() => {
if (wrapper) {
wrapper.destroy();
+ wrapper = null;
}
});
@@ -192,6 +192,17 @@ describe('AlertManagementTable', () => {
).toContain('gl-hover-bg-blue-50');
});
+ it('displays the alert ID and title formatted correctly', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ loading: false,
+ });
+
+ expect(findFirstIDField().exists()).toBe(true);
+ expect(findFirstIDField().text()).toBe(`#${mockAlerts[0].iid} ${mockAlerts[0].title}`);
+ });
+
it('displays status dropdown', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
@@ -207,7 +218,11 @@ describe('AlertManagementTable', () => {
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false,
});
- expect(findStatusDropdown().contains('.dropdown-title')).toBe(false);
+ expect(
+ findStatusDropdown()
+ .find('.dropdown-title')
+ .exists(),
+ ).toBe(false);
});
it('shows correct severity icons', () => {
@@ -255,18 +270,22 @@ describe('AlertManagementTable', () => {
).toBe('Unassigned');
});
- it('renders username(s) when assignee(s) present', () => {
+ it('renders user avatar when assignee present', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false,
});
- expect(
- findAssignees()
- .at(1)
- .text(),
- ).toBe(mockAlerts[1].assignees.nodes[0].username);
+ const avatar = findAssignees()
+ .at(1)
+ .find(GlAvatar);
+ const { src, label } = avatar.attributes();
+ const { name, avatarUrl } = mockAlerts[1].assignees.nodes[0];
+
+ expect(avatar.exists()).toBe(true);
+ expect(label).toBe(name);
+ expect(src).toBe(avatarUrl);
});
it('navigates to the detail page when alert row is clicked', () => {
@@ -502,7 +521,11 @@ describe('AlertManagementTable', () => {
await selectFirstStatusOption();
expect(findAlertError().exists()).toBe(true);
- expect(findAlertError().contains('[data-testid="htmlError"]')).toBe(true);
+ expect(
+ findAlertError()
+ .find('[data-testid="htmlError"]')
+ .exists(),
+ ).toBe(true);
});
});
diff --git a/spec/frontend/alert_management/components/alert_metrics_spec.js b/spec/frontend/alert_management/components/alert_metrics_spec.js
index e0a069fa1a8..42da8c3768b 100644
--- a/spec/frontend/alert_management/components/alert_metrics_spec.js
+++ b/spec/frontend/alert_management/components/alert_metrics_spec.js
@@ -3,15 +3,13 @@ import waitForPromises from 'helpers/wait_for_promises';
import MockAdapter from 'axios-mock-adapter';
import axios from 'axios';
import AlertMetrics from '~/alert_management/components/alert_metrics.vue';
+import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue';
jest.mock('~/monitoring/stores', () => ({
monitoringDashboard: {},
}));
-const mockEmbedName = 'MetricsEmbedStub';
-
jest.mock('~/monitoring/components/embeds/metric_embed.vue', () => ({
- name: mockEmbedName,
render(h) {
return h('div');
},
@@ -26,13 +24,10 @@ describe('Alert Metrics', () => {
propsData: {
...props,
},
- stubs: {
- MetricEmbed: true,
- },
});
}
- const findChart = () => wrapper.find({ name: mockEmbedName });
+ const findChart = () => wrapper.find(MetricEmbed);
const findEmptyState = () => wrapper.find({ ref: 'emptyState' });
afterEach(() => {
diff --git a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js
index a14596b6722..4c9db02eff4 100644
--- a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js
+++ b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js
@@ -56,6 +56,9 @@ describe('Alert Details Sidebar Assignees', () => {
mock.restore();
});
+ const findAssigned = () => wrapper.find('[data-testid="assigned-users"]');
+ const findUnassigned = () => wrapper.find('[data-testid="unassigned-users"]');
+
describe('updating the alert status', () => {
const mockUpdatedMutationResult = {
data: {
@@ -100,32 +103,30 @@ describe('Alert Details Sidebar Assignees', () => {
});
});
- it('renders a unassigned option', () => {
+ it('renders a unassigned option', async () => {
wrapper.setData({ isDropdownSearching: false });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlDeprecatedDropdownItem).text()).toBe('Unassigned');
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find(GlDeprecatedDropdownItem).text()).toBe('Unassigned');
});
- it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', () => {
+ it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
wrapper.setData({ isDropdownSearching: false });
- return wrapper.vm.$nextTick().then(() => {
- wrapper.find(SidebarAssignee).vm.$emit('update-alert-assignees', 'root');
+ await wrapper.vm.$nextTick();
+ wrapper.find(SidebarAssignee).vm.$emit('update-alert-assignees', 'root');
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: AlertSetAssignees,
- variables: {
- iid: '1527542',
- assigneeUsernames: ['root'],
- projectPath: 'projectPath',
- },
- });
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: AlertSetAssignees,
+ variables: {
+ iid: '1527542',
+ assigneeUsernames: ['root'],
+ projectPath: 'projectPath',
+ },
});
});
- it('shows an error when request contains error messages', () => {
+ it('emits an error when request contains error messages', () => {
wrapper.setData({ isDropdownSearching: false });
const errorMutationResult = {
data: {
@@ -137,18 +138,48 @@ describe('Alert Details Sidebar Assignees', () => {
};
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(errorMutationResult);
-
- return wrapper.vm.$nextTick().then(() => {
- const SideBarAssigneeItem = wrapper.findAll(SidebarAssignee).at(0);
- SideBarAssigneeItem.vm.$emit('click');
- expect(wrapper.emitted('alert-refresh')).toBeUndefined();
- });
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ const SideBarAssigneeItem = wrapper.findAll(SidebarAssignee).at(0);
+ SideBarAssigneeItem.vm.$emit('update-alert-assignees');
+ })
+ .then(() => {
+ expect(wrapper.emitted('alert-error')).toBeDefined();
+ });
});
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(wrapper.find('[data-testid="unassigned-users"]').text()).toBe('assign yourself');
+ 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,
+ },
+ });
+
+ expect(
+ findAssigned()
+ .find('img')
+ .attributes('src'),
+ ).toBe('/url');
+ expect(
+ findAssigned()
+ .find('.dropdown-menu-user-full-name')
+ .text(),
+ ).toBe('root');
+ expect(
+ findAssigned()
+ .find('.dropdown-menu-user-username')
+ .text(),
+ ).toBe('root');
});
});
});
diff --git a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
index 5bd0d3b3c17..a8fe40687e1 100644
--- a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
+++ b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
@@ -56,7 +56,11 @@ describe('Alert Details Sidebar Status', () => {
});
it('displays the dropdown status header', () => {
- expect(findStatusDropdown().contains('.dropdown-title')).toBe(true);
+ expect(
+ findStatusDropdown()
+ .find('.dropdown-title')
+ .exists(),
+ ).toBe(true);
});
describe('updating the alert status', () => {
diff --git a/spec/frontend/alert_management/mocks/alerts.json b/spec/frontend/alert_management/mocks/alerts.json
index fec101a52b4..5267a4fe50d 100644
--- a/spec/frontend/alert_management/mocks/alerts.json
+++ b/spec/frontend/alert_management/mocks/alerts.json
@@ -20,7 +20,7 @@
"startedAt": "2020-04-17T23:18:14.996Z",
"endedAt": "2020-04-17T23:18:14.996Z",
"status": "ACKNOWLEDGED",
- "assignees": { "nodes": [{ "username": "root" }] },
+ "assignees": { "nodes": [{ "username": "root", "avatarUrl": "/url", "name": "root" }] },
"issueIid": "1",
"notes": {
"nodes": [
@@ -49,7 +49,7 @@
"startedAt": "2020-04-17T23:18:14.996Z",
"endedAt": "2020-04-17T23:18:14.996Z",
"status": "RESOLVED",
- "assignees": { "nodes": [{ "username": "root" }] },
+ "assignees": { "nodes": [{ "username": "root", "avatarUrl": "/url", "name": "root" }] },
"notes": {
"nodes": [
{
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index 4f4de62c229..3ae0d06162d 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -398,6 +398,29 @@ describe('Api', () => {
});
});
+ describe('projectMilestones', () => {
+ it('fetches project milestones', done => {
+ const projectId = 1;
+ const options = { state: 'active' };
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/milestones`;
+ mock.onGet(expectedUrl).reply(200, [
+ {
+ id: 1,
+ title: 'milestone1',
+ state: 'active',
+ },
+ ]);
+
+ Api.projectMilestones(projectId, options)
+ .then(({ data }) => {
+ expect(data.length).toBe(1);
+ expect(data[0].title).toBe('milestone1');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
describe('newLabel', () => {
it('creates a new label', done => {
const namespace = 'some namespace';
diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js
index 8abef2ae1b2..7a87b420195 100644
--- a/spec/frontend/authentication/u2f/authenticate_spec.js
+++ b/spec/frontend/authentication/u2f/authenticate_spec.js
@@ -76,7 +76,7 @@ describe('U2FAuthenticate', () => {
describe('errors', () => {
it('displays an error message', () => {
- const setupButton = container.find('#js-login-u2f-device');
+ const setupButton = container.find('#js-login-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToAuthenticateRequest({
errorCode: 'error!',
@@ -87,14 +87,14 @@ describe('U2FAuthenticate', () => {
});
it('allows retrying authentication after an error', () => {
- let setupButton = container.find('#js-login-u2f-device');
+ 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-u2f-device');
+ setupButton = container.find('#js-login-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToAuthenticateRequest({
deviceData: 'this is data from the device',
diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js
index 3c2ecdbba66..e89ef773be6 100644
--- a/spec/frontend/authentication/u2f/register_spec.js
+++ b/spec/frontend/authentication/u2f/register_spec.js
@@ -13,8 +13,8 @@ describe('U2FRegister', () => {
beforeEach(done => {
loadFixtures('u2f/register.html');
u2fDevice = new MockU2FDevice();
- container = $('#js-register-u2f');
- component = new U2FRegister(container, $('#js-register-u2f-templates'), {}, 'token');
+ container = $('#js-register-token-2fa');
+ component = new U2FRegister(container, {});
component
.start()
.then(done)
@@ -22,9 +22,9 @@ describe('U2FRegister', () => {
});
it('allows registering a U2F device', () => {
- const setupButton = container.find('#js-setup-u2f-device');
+ const setupButton = container.find('#js-setup-token-2fa-device');
- expect(setupButton.text()).toBe('Set up new U2F device');
+ expect(setupButton.text()).toBe('Set up new device');
setupButton.trigger('click');
const inProgressMessage = container.children('p');
@@ -41,7 +41,7 @@ describe('U2FRegister', () => {
describe('errors', () => {
it("doesn't allow the same device to be registered twice (for the same user", () => {
- const setupButton = container.find('#js-setup-u2f-device');
+ const setupButton = container.find('#js-setup-token-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToRegisterRequest({
errorCode: 4,
@@ -52,7 +52,7 @@ describe('U2FRegister', () => {
});
it('displays an error message for other errors', () => {
- const setupButton = container.find('#js-setup-u2f-device');
+ const setupButton = container.find('#js-setup-token-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToRegisterRequest({
errorCode: 'error!',
@@ -63,14 +63,14 @@ describe('U2FRegister', () => {
});
it('allows retrying registration after an error', () => {
- let setupButton = container.find('#js-setup-u2f-device');
+ let setupButton = container.find('#js-setup-token-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToRegisterRequest({
errorCode: 'error!',
});
- const retryButton = container.find('#U2FTryAgain');
+ const retryButton = container.find('#js-token-2fa-try-again');
retryButton.trigger('click');
- setupButton = container.find('#js-setup-u2f-device');
+ setupButton = container.find('#js-setup-token-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToRegisterRequest({
deviceData: 'this is data from the device',
diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js
new file mode 100644
index 00000000000..0a82adfd0ee
--- /dev/null
+++ b/spec/frontend/authentication/webauthn/authenticate_spec.js
@@ -0,0 +1,132 @@
+import $ from 'jquery';
+import waitForPromises from 'helpers/wait_for_promises';
+import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate';
+import MockWebAuthnDevice from './mock_webauthn_device';
+import { useMockNavigatorCredentials } from './util';
+
+const mockResponse = {
+ type: 'public-key',
+ id: '',
+ rawId: '',
+ response: { clientDataJSON: '', authenticatorData: '', signature: '', userHandle: '' },
+ getClientExtensionResults: () => {},
+};
+
+describe('WebAuthnAuthenticate', () => {
+ preloadFixtures('webauthn/authenticate.html');
+ useMockNavigatorCredentials();
+
+ let fallbackElement;
+ let webAuthnDevice;
+ let container;
+ let component;
+ let submitSpy;
+
+ const findDeviceResponseInput = () => container[0].querySelector('#js-device-response');
+ const findDeviceResponseInputValue = () => findDeviceResponseInput().value;
+ const findMessage = () => container[0].querySelector('p');
+ const findRetryButton = () => container[0].querySelector('#js-token-2fa-try-again');
+ const expectAuthenticated = () => {
+ expect(container.text()).toMatchInterpolatedText(
+ 'We heard back from your device. You have been authenticated.',
+ );
+ expect(findDeviceResponseInputValue()).toBe(JSON.stringify(mockResponse));
+ expect(submitSpy).toHaveBeenCalled();
+ };
+
+ beforeEach(() => {
+ loadFixtures('webauthn/authenticate.html');
+ fallbackElement = document.createElement('div');
+ fallbackElement.classList.add('js-2fa-form');
+ webAuthnDevice = new MockWebAuthnDevice();
+ container = $('#js-authenticate-token-2fa');
+ component = new WebAuthnAuthenticate(
+ container,
+ '#js-login-token-2fa-form',
+ {
+ options:
+ // we need some valid base64 for base64ToBuffer
+ // so we use "YQ==" = base64("a")
+ JSON.stringify({
+ challenge: 'YQ==',
+ timeout: 120000,
+ allowCredentials: [
+ { type: 'public-key', id: 'YQ==' },
+ { type: 'public-key', id: 'YQ==' },
+ ],
+ userVerification: 'discouraged',
+ }),
+ },
+ document.querySelector('#js-login-2fa-device'),
+ fallbackElement,
+ );
+ submitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit');
+ });
+
+ describe('with webauthn unavailable', () => {
+ let oldGetCredentials;
+
+ beforeEach(() => {
+ oldGetCredentials = window.navigator.credentials.get;
+ window.navigator.credentials.get = null;
+ });
+
+ afterEach(() => {
+ window.navigator.credentials.get = oldGetCredentials;
+ });
+
+ it('falls back to normal 2fa', () => {
+ component.start();
+
+ expect(container.html()).toBe('');
+ expect(container[0]).toHaveClass('hidden');
+ expect(fallbackElement).not.toHaveClass('hidden');
+ });
+ });
+
+ describe('with webauthn available', () => {
+ beforeEach(() => {
+ component.start();
+ });
+
+ it('shows in progress', () => {
+ const inProgressMessage = container.find('p');
+
+ expect(inProgressMessage.text()).toMatchInterpolatedText(
+ "Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.",
+ );
+ });
+
+ it('allows authenticating via a WebAuthn device', () => {
+ webAuthnDevice.respondToAuthenticateRequest(mockResponse);
+
+ return waitForPromises().then(() => {
+ expectAuthenticated();
+ });
+ });
+
+ describe('errors', () => {
+ beforeEach(() => {
+ webAuthnDevice.rejectAuthenticateRequest(new DOMException());
+
+ return waitForPromises();
+ });
+
+ it('displays an error message', () => {
+ expect(submitSpy).not.toHaveBeenCalled();
+ expect(findMessage().textContent).toMatchInterpolatedText(
+ 'There was a problem communicating with your device. (Error)',
+ );
+ });
+
+ it('allows retrying authentication after an error', () => {
+ findRetryButton().click();
+ webAuthnDevice.respondToAuthenticateRequest(mockResponse);
+
+ return waitForPromises().then(() => {
+ expectAuthenticated();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/authentication/webauthn/error_spec.js b/spec/frontend/authentication/webauthn/error_spec.js
new file mode 100644
index 00000000000..26f1ca5e27d
--- /dev/null
+++ b/spec/frontend/authentication/webauthn/error_spec.js
@@ -0,0 +1,50 @@
+import WebAuthnError from '~/authentication/webauthn/error';
+
+describe('WebAuthnError', () => {
+ it.each([
+ [
+ 'NotSupportedError',
+ 'Your device is not compatible with GitLab. Please try another device',
+ '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'],
+ ])('exception %s will have message %s, flow type: %s', (exception, expectedMessage, flowType) => {
+ expect(new WebAuthnError(new DOMException('', exception), flowType).message()).toEqual(
+ expectedMessage,
+ );
+ });
+
+ describe('SecurityError', () => {
+ const { location } = window;
+
+ beforeEach(() => {
+ delete window.location;
+ window.location = {};
+ });
+
+ afterEach(() => {
+ window.location = location;
+ });
+
+ it('returns a descriptive error if https is disabled', () => {
+ window.location.protocol = 'http:';
+
+ const expectedMessage =
+ 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.';
+ expect(
+ new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(),
+ ).toEqual(expectedMessage);
+ });
+
+ it('returns a generic error if https is enabled', () => {
+ window.location.protocol = 'https:';
+
+ const expectedMessage = 'There was a problem communicating with your device.';
+ expect(
+ new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(),
+ ).toEqual(expectedMessage);
+ });
+ });
+});
diff --git a/spec/frontend/authentication/webauthn/mock_webauthn_device.js b/spec/frontend/authentication/webauthn/mock_webauthn_device.js
new file mode 100644
index 00000000000..39df94df46b
--- /dev/null
+++ b/spec/frontend/authentication/webauthn/mock_webauthn_device.js
@@ -0,0 +1,35 @@
+/* eslint-disable no-unused-expressions */
+
+export default class MockWebAuthnDevice {
+ constructor() {
+ this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this);
+ this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this);
+ window.navigator.credentials || (window.navigator.credentials = {});
+ window.navigator.credentials.create = () =>
+ new Promise((resolve, reject) => {
+ this.registerCallback = resolve;
+ this.registerRejectCallback = reject;
+ });
+ window.navigator.credentials.get = () =>
+ new Promise((resolve, reject) => {
+ this.authenticateCallback = resolve;
+ this.authenticateRejectCallback = reject;
+ });
+ }
+
+ respondToRegisterRequest(params) {
+ return this.registerCallback(params);
+ }
+
+ respondToAuthenticateRequest(params) {
+ return this.authenticateCallback(params);
+ }
+
+ rejectRegisterRequest(params) {
+ return this.registerRejectCallback(params);
+ }
+
+ rejectAuthenticateRequest(params) {
+ return this.authenticateRejectCallback(params);
+ }
+}
diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js
new file mode 100644
index 00000000000..1de952d176d
--- /dev/null
+++ b/spec/frontend/authentication/webauthn/register_spec.js
@@ -0,0 +1,131 @@
+import $ from 'jquery';
+import waitForPromises from 'helpers/wait_for_promises';
+import WebAuthnRegister from '~/authentication/webauthn/register';
+import MockWebAuthnDevice from './mock_webauthn_device';
+import { useMockNavigatorCredentials } from './util';
+
+describe('WebAuthnRegister', () => {
+ preloadFixtures('webauthn/register.html');
+ useMockNavigatorCredentials();
+
+ const mockResponse = {
+ type: 'public-key',
+ id: '',
+ rawId: '',
+ response: {
+ clientDataJSON: '',
+ attestationObject: '',
+ },
+ getClientExtensionResults: () => {},
+ };
+ let webAuthnDevice;
+ let container;
+ let component;
+
+ beforeEach(() => {
+ loadFixtures('webauthn/register.html');
+ webAuthnDevice = new MockWebAuthnDevice();
+ container = $('#js-register-token-2fa');
+ component = new WebAuthnRegister(container, {
+ options: {
+ rp: '',
+ user: {
+ id: '',
+ name: '',
+ displayName: '',
+ },
+ challenge: '',
+ pubKeyCredParams: '',
+ },
+ });
+ component.start();
+ });
+
+ const findSetupButton = () => container.find('#js-setup-token-2fa-device');
+ const findMessage = () => container.find('p');
+ const findDeviceResponse = () => container.find('#js-device-response');
+ const findRetryButton = () => container.find('#js-token-2fa-try-again');
+
+ it('shows setup button', () => {
+ expect(findSetupButton().text()).toBe('Set up new device');
+ });
+
+ describe('when unsupported', () => {
+ const { location, PublicKeyCredential } = window;
+
+ beforeEach(() => {
+ delete window.location;
+ delete window.credentials;
+ window.location = {};
+ window.PublicKeyCredential = undefined;
+ });
+
+ afterEach(() => {
+ window.location = location;
+ window.PublicKeyCredential = PublicKeyCredential;
+ });
+
+ it.each`
+ httpsEnabled | expectedText
+ ${false} | ${'WebAuthn only works with HTTPS-enabled websites'}
+ ${true} | ${'Please use a supported browser, e.g. Chrome (67+) or Firefox'}
+ `('when https is $httpsEnabled', ({ httpsEnabled, expectedText }) => {
+ window.location.protocol = httpsEnabled ? 'https:' : 'http:';
+ component.start();
+
+ expect(findMessage().text()).toContain(expectedText);
+ });
+ });
+
+ describe('when setup', () => {
+ beforeEach(() => {
+ findSetupButton().trigger('click');
+ });
+
+ it('shows in progress message', () => {
+ expect(findMessage().text()).toContain('Trying to communicate with your device');
+ });
+
+ it('registers device', () => {
+ webAuthnDevice.respondToRegisterRequest(mockResponse);
+
+ return waitForPromises().then(() => {
+ expect(findMessage().text()).toContain('Your device was successfully set up!');
+ expect(findDeviceResponse().val()).toBe(JSON.stringify(mockResponse));
+ });
+ });
+
+ it.each`
+ errorName | expectedText
+ ${'NotSupportedError'} | ${'Your device is not compatible with GitLab'}
+ ${'NotAllowedError'} | ${'There was a problem communicating with your device'}
+ `('when fails with $errorName', ({ errorName, expectedText }) => {
+ webAuthnDevice.rejectRegisterRequest(new DOMException('', errorName));
+
+ return waitForPromises().then(() => {
+ expect(findMessage().text()).toContain(expectedText);
+ expect(findRetryButton().length).toBe(1);
+ });
+ });
+
+ it('can retry', () => {
+ webAuthnDevice.respondToRegisterRequest({
+ errorCode: 'error!',
+ });
+
+ return waitForPromises()
+ .then(() => {
+ findRetryButton().click();
+
+ expect(findMessage().text()).toContain('Trying to communicate with your device');
+
+ webAuthnDevice.respondToRegisterRequest(mockResponse);
+ return waitForPromises();
+ })
+ .then(() => {
+ expect(findMessage().text()).toContain('Your device was successfully set up!');
+ expect(findDeviceResponse().val()).toBe(JSON.stringify(mockResponse));
+ });
+ });
+ });
+});
diff --git a/spec/frontend/authentication/webauthn/util.js b/spec/frontend/authentication/webauthn/util.js
new file mode 100644
index 00000000000..d8f5a67ee1f
--- /dev/null
+++ b/spec/frontend/authentication/webauthn/util.js
@@ -0,0 +1,19 @@
+export function useMockNavigatorCredentials() {
+ let oldNavigatorCredentials;
+ let oldPublicKeyCredential;
+
+ beforeEach(() => {
+ oldNavigatorCredentials = navigator.credentials;
+ oldPublicKeyCredential = window.PublicKeyCredential;
+ navigator.credentials = {
+ get: jest.fn(),
+ create: jest.fn(),
+ };
+ window.PublicKeyCredential = function MockPublicKeyCredential() {};
+ });
+
+ afterEach(() => {
+ navigator.credentials = oldNavigatorCredentials;
+ window.PublicKeyCredential = oldPublicKeyCredential;
+ });
+}
diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js
index 1a1738ecf4a..f0ed18248f0 100644
--- a/spec/frontend/awards_handler_spec.js
+++ b/spec/frontend/awards_handler_spec.js
@@ -4,7 +4,6 @@ import MockAdapter from 'axios-mock-adapter';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import axios from '~/lib/utils/axios_utils';
import loadAwardsHandler from '~/awards_handler';
-import { setTestTimeout } from './helpers/timeout';
import { EMOJI_VERSION } from '~/emoji';
window.gl = window.gl || {};
@@ -17,7 +16,44 @@ const urlRoot = gon.relative_url_root;
describe('AwardsHandler', () => {
useFakeRequestAnimationFrame();
- const emojiData = getJSONFixture('emojis/emojis.json');
+ const emojiData = {
+ '8ball': {
+ c: 'activity',
+ e: '🎱',
+ d: 'billiards',
+ u: '6.0',
+ },
+ grinning: {
+ c: 'people',
+ e: '😀',
+ d: 'grinning face',
+ u: '6.1',
+ },
+ angel: {
+ c: 'people',
+ e: '👼',
+ d: 'baby angel',
+ u: '6.0',
+ },
+ anger: {
+ c: 'symbols',
+ e: '💢',
+ d: 'anger symbol',
+ u: '6.0',
+ },
+ alien: {
+ c: 'people',
+ e: '👽',
+ d: 'extraterrestrial alien',
+ u: '6.0',
+ },
+ sunglasses: {
+ c: 'people',
+ e: '😎',
+ d: 'smiling face with sunglasses',
+ u: '6.0',
+ },
+ };
preloadFixtures('snippets/show.html');
const openAndWaitForEmojiMenu = (sel = '.js-add-award') => {
@@ -25,7 +61,7 @@ describe('AwardsHandler', () => {
.eq(0)
.click();
- jest.advanceTimersByTime(200);
+ jest.runOnlyPendingTimers();
const $menu = $('.emoji-menu');
@@ -37,10 +73,6 @@ describe('AwardsHandler', () => {
};
beforeEach(async () => {
- // These tests have had some timeout issues
- // https://gitlab.com/gitlab-org/gitlab/-/issues/221086
- setTestTimeout(6000);
-
mock = new MockAdapter(axios);
mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData);
diff --git a/spec/frontend/badges/components/badge_settings_spec.js b/spec/frontend/badges/components/badge_settings_spec.js
index 8c3f1ea2749..b6a86746598 100644
--- a/spec/frontend/badges/components/badge_settings_spec.js
+++ b/spec/frontend/badges/components/badge_settings_spec.js
@@ -82,14 +82,14 @@ describe('BadgeSettings component', () => {
const form = vm.$el.querySelector('form:nth-of-type(1)');
expect(form).not.toBe(null);
- const submitButton = form.querySelector('.btn-success');
-
- expect(submitButton).not.toBe(null);
- expect(submitButton).toHaveText(/Save changes/);
- const cancelButton = form.querySelector('.btn-cancel');
+ const cancelButton = form.querySelector('[data-testid="cancelEditing"]');
expect(cancelButton).not.toBe(null);
expect(cancelButton).toHaveText(/Cancel/);
+ const submitButton = form.querySelector('[data-testid="saveEditing"]');
+
+ expect(submitButton).not.toBe(null);
+ expect(submitButton).toHaveText(/Save changes/);
});
it('displays no badge list', () => {
diff --git a/spec/frontend/batch_comments/mock_data.js b/spec/frontend/batch_comments/mock_data.js
index 5601e489066..973f0d469d4 100644
--- a/spec/frontend/batch_comments/mock_data.js
+++ b/spec/frontend/batch_comments/mock_data.js
@@ -1,6 +1,5 @@
import { TEST_HOST } from 'spec/test_constants';
-// eslint-disable-next-line import/prefer-default-export
export const createDraft = () => ({
author: {
id: 1,
diff --git a/spec/frontend/behaviors/autosize_spec.js b/spec/frontend/behaviors/autosize_spec.js
index 59abae479d4..3444c7b4075 100644
--- a/spec/frontend/behaviors/autosize_spec.js
+++ b/spec/frontend/behaviors/autosize_spec.js
@@ -1,20 +1,24 @@
-import $ from 'jquery';
import '~/behaviors/autosize';
function load() {
- $(document).trigger('load');
+ document.dispatchEvent(new Event('DOMContentLoaded'));
}
+jest.mock('~/helpers/startup_css_helper', () => {
+ return {
+ waitForCSSLoaded: jest.fn().mockImplementation(cb => cb.apply()),
+ };
+});
+
describe('Autosize behavior', () => {
beforeEach(() => {
- setFixtures('<textarea class="js-autosize" style="resize: vertical"></textarea>');
+ setFixtures('<textarea class="js-autosize"></textarea>');
});
- it('does not overwrite the resize property', () => {
+ it('is applied to the textarea', () => {
load();
- expect($('textarea')).toHaveCss({
- resize: 'vertical',
- });
+ const textarea = document.querySelector('textarea');
+ expect(textarea.classList).toContain('js-autosize-initialized');
});
});
diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js
index ef6b1673b7c..46b4e5d3d5c 100644
--- a/spec/frontend/behaviors/gl_emoji_spec.js
+++ b/spec/frontend/behaviors/gl_emoji_spec.js
@@ -10,7 +10,20 @@ jest.mock('~/emoji/support');
describe('gl_emoji', () => {
let mock;
- const emojiData = getJSONFixture('emojis/emojis.json');
+ const emojiData = {
+ grey_question: {
+ c: 'symbols',
+ e: '❔',
+ d: 'white question mark ornament',
+ u: '6.0',
+ },
+ bomb: {
+ c: 'objects',
+ e: '💣',
+ d: 'bomb',
+ u: '6.0',
+ },
+ };
beforeAll(() => {
jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true);
diff --git a/spec/frontend/blob/components/blob_content_spec.js b/spec/frontend/blob/components/blob_content_spec.js
index 9232a709194..3db95e5ad3f 100644
--- a/spec/frontend/blob/components/blob_content_spec.js
+++ b/spec/frontend/blob/components/blob_content_spec.js
@@ -36,20 +36,20 @@ describe('Blob Content component', () => {
describe('rendering', () => {
it('renders loader if `loading: true`', () => {
createComponent({ loading: true });
- expect(wrapper.contains(GlLoadingIcon)).toBe(true);
- expect(wrapper.contains(BlobContentError)).toBe(false);
- expect(wrapper.contains(RichViewer)).toBe(false);
- expect(wrapper.contains(SimpleViewer)).toBe(false);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.find(BlobContentError).exists()).toBe(false);
+ expect(wrapper.find(RichViewer).exists()).toBe(false);
+ expect(wrapper.find(SimpleViewer).exists()).toBe(false);
});
it('renders error if there is any in the viewer', () => {
const renderError = 'Oops';
const viewer = { ...SimpleViewerMock, renderError };
createComponent({}, viewer);
- expect(wrapper.contains(GlLoadingIcon)).toBe(false);
- expect(wrapper.contains(BlobContentError)).toBe(true);
- expect(wrapper.contains(RichViewer)).toBe(false);
- expect(wrapper.contains(SimpleViewer)).toBe(false);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find(BlobContentError).exists()).toBe(true);
+ expect(wrapper.find(RichViewer).exists()).toBe(false);
+ expect(wrapper.find(SimpleViewer).exists()).toBe(false);
});
it.each`
@@ -60,7 +60,7 @@ describe('Blob Content component', () => {
'renders $type viewer when activeViewer is $type and no loading or error detected',
({ mock, viewer }) => {
createComponent({}, mock);
- expect(wrapper.contains(viewer)).toBe(true);
+ expect(wrapper.find(viewer).exists()).toBe(true);
},
);
diff --git a/spec/frontend/blob/components/blob_edit_content_spec.js b/spec/frontend/blob/components/blob_edit_content_spec.js
index 3cc210e972c..dbed086a552 100644
--- a/spec/frontend/blob/components/blob_edit_content_spec.js
+++ b/spec/frontend/blob/components/blob_edit_content_spec.js
@@ -2,12 +2,14 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import BlobEditContent from '~/blob/components/blob_edit_content.vue';
import * as utils from '~/blob/utils';
-import Editor from '~/editor/editor_lite';
jest.mock('~/editor/editor_lite');
describe('Blob Header Editing', () => {
let wrapper;
+ const onDidChangeModelContent = jest.fn();
+ const updateModelLanguage = jest.fn();
+ const getValue = jest.fn();
const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const fileName = 'lorem.txt';
const fileGlobalId = 'snippet_777';
@@ -24,7 +26,12 @@ describe('Blob Header Editing', () => {
}
beforeEach(() => {
- jest.spyOn(utils, 'initEditorLite');
+ jest.spyOn(utils, 'initEditorLite').mockImplementation(() => ({
+ onDidChangeModelContent,
+ updateModelLanguage,
+ getValue,
+ dispose: jest.fn(),
+ }));
createComponent();
});
@@ -34,8 +41,8 @@ describe('Blob Header Editing', () => {
});
const triggerChangeContent = val => {
- jest.spyOn(Editor.prototype, 'getValue').mockReturnValue(val);
- const [cb] = Editor.prototype.onChangeContent.mock.calls[0];
+ getValue.mockReturnValue(val);
+ const [cb] = onDidChangeModelContent.mock.calls[0];
cb();
@@ -58,7 +65,7 @@ describe('Blob Header Editing', () => {
createComponent({ value: undefined });
expect(spy).not.toHaveBeenCalled();
- expect(wrapper.contains('#editor')).toBe(true);
+ expect(wrapper.find('#editor').exists()).toBe(true);
});
it('initialises Editor Lite', () => {
@@ -79,12 +86,12 @@ describe('Blob Header Editing', () => {
});
return nextTick().then(() => {
- expect(Editor.prototype.updateModelLanguage).toHaveBeenCalledWith(newFileName);
+ expect(updateModelLanguage).toHaveBeenCalledWith(newFileName);
});
});
it('registers callback with editor onChangeContent', () => {
- expect(Editor.prototype.onChangeContent).toHaveBeenCalledWith(expect.any(Function));
+ expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function));
});
it('emits input event when the blob content is changed', () => {
diff --git a/spec/frontend/blob/components/blob_edit_header_spec.js b/spec/frontend/blob/components/blob_edit_header_spec.js
index c71595a79cf..4355f46db7e 100644
--- a/spec/frontend/blob/components/blob_edit_header_spec.js
+++ b/spec/frontend/blob/components/blob_edit_header_spec.js
@@ -31,7 +31,7 @@ describe('Blob Header Editing', () => {
});
it('contains a form input field', () => {
- expect(wrapper.contains(GlFormInput)).toBe(true);
+ expect(wrapper.find(GlFormInput).exists()).toBe(true);
});
it('does not show delete button', () => {
diff --git a/spec/frontend/blob/components/blob_embeddable_spec.js b/spec/frontend/blob/components/blob_embeddable_spec.js
deleted file mode 100644
index 1f6790013ca..00000000000
--- a/spec/frontend/blob/components/blob_embeddable_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlFormInputGroup } from '@gitlab/ui';
-import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
-
-describe('Blob Embeddable', () => {
- let wrapper;
- const url = 'https://foo.bar';
-
- function createComponent() {
- wrapper = shallowMount(BlobEmbeddable, {
- propsData: {
- url,
- },
- });
- }
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders gl-form-input-group component', () => {
- expect(wrapper.find(GlFormInputGroup).exists()).toBe(true);
- });
-
- it('makes up optionValues based on the url prop', () => {
- expect(wrapper.vm.optionValues).toEqual([
- { name: 'Embed', value: expect.stringContaining(`${url}.js`) },
- { name: 'Share', value: url },
- ]);
- });
-});
diff --git a/spec/frontend/blob/pipeline_tour_success_mock_data.js b/spec/frontend/blob/pipeline_tour_success_mock_data.js
index 7819fcce85d..9dea3969d63 100644
--- a/spec/frontend/blob/pipeline_tour_success_mock_data.js
+++ b/spec/frontend/blob/pipeline_tour_success_mock_data.js
@@ -1,5 +1,6 @@
const modalProps = {
goToPipelinesPath: 'some_pipeline_path',
+ projectMergeRequestsPath: 'some_mr_path',
commitCookie: 'some_cookie',
humanAccess: 'maintainer',
};
diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
index 9998cd7f91c..50db1675e13 100644
--- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js
+++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
@@ -10,10 +10,7 @@ describe('PipelineTourSuccessModal', () => {
let cookieSpy;
let trackingSpy;
- beforeEach(() => {
- document.body.dataset.page = 'projects:blob:show';
- trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
-
+ const createComponent = () => {
wrapper = shallowMount(pipelineTourSuccess, {
propsData: modalProps,
stubs: {
@@ -21,13 +18,49 @@ describe('PipelineTourSuccessModal', () => {
GlSprintf,
},
});
+ };
+ beforeEach(() => {
+ document.body.dataset.page = 'projects:blob:show';
+ trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
cookieSpy = jest.spyOn(Cookies, 'remove');
+ createComponent();
});
afterEach(() => {
wrapper.destroy();
unmockTracking();
+ Cookies.remove(modalProps.commitCookie);
+ });
+
+ describe('when the commitCookie contains the mr path', () => {
+ const expectedMrPath = 'expected_mr_path';
+
+ beforeEach(() => {
+ Cookies.set(modalProps.commitCookie, expectedMrPath);
+ createComponent();
+ });
+
+ it('renders the path from the commit cookie for back to the merge request button', () => {
+ const goToMrBtn = wrapper.find({ ref: 'goToMergeRequest' });
+
+ expect(goToMrBtn.attributes('href')).toBe(expectedMrPath);
+ });
+ });
+
+ describe('when the commitCookie does not contain mr path', () => {
+ const expectedMrPath = modalProps.projectMergeRequestsPath;
+
+ beforeEach(() => {
+ Cookies.set(modalProps.commitCookie, true);
+ createComponent();
+ });
+
+ it('renders the path from projectMergeRequestsPath for back to the merge request button', () => {
+ const goToMrBtn = wrapper.find({ ref: 'goToMergeRequest' });
+
+ expect(goToMrBtn.attributes('href')).toBe(expectedMrPath);
+ });
});
it('has expected structure', () => {
@@ -58,7 +91,7 @@ describe('PipelineTourSuccessModal', () => {
it('send an event when go to pipelines is clicked', () => {
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
- const goToBtn = wrapper.find({ ref: 'goto' });
+ const goToBtn = wrapper.find({ ref: 'goToPipelines' });
triggerEvent(goToBtn.element);
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', {
@@ -67,5 +100,17 @@ describe('PipelineTourSuccessModal', () => {
value: '10',
});
});
+
+ it('sends an event when back to the merge request is clicked', () => {
+ trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
+ const goToBtn = wrapper.find({ ref: 'goToMergeRequest' });
+ triggerEvent(goToBtn.element);
+
+ expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'congratulate_first_pipeline',
+ property: modalProps.humanAccess,
+ value: '20',
+ });
+ });
});
});
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 4714d34dbec..e55b8e4af24 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
@@ -16,6 +16,7 @@ const commitTrackLabel = 'suggest_commit_first_project_gitlab_ci_yml';
const dismissCookie = 'suggest_gitlab_ci_yml_99';
const humanAccess = 'owner';
+const mergeRequestPath = '/some/path';
describe('Suggest gitlab-ci.yml Popover', () => {
let wrapper;
@@ -26,10 +27,11 @@ describe('Suggest gitlab-ci.yml Popover', () => {
target,
trackLabel,
dismissKey,
+ mergeRequestPath,
humanAccess,
},
stubs: {
- 'gl-popover': '<div><slot name="title"></slot><slot></slot></div>',
+ 'gl-popover': { template: '<div><slot name="title"></slot><slot></slot></div>' },
},
});
}
diff --git a/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js b/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js
new file mode 100644
index 00000000000..8dc71f99010
--- /dev/null
+++ b/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js
@@ -0,0 +1,67 @@
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMount } from '@vue/test-utils';
+import { GlButton, GlAlert } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import WebIdeAlert from '~/blob/suggest_web_ide_ci/components/web_ide_alert.vue';
+
+const dismissEndpoint = '/-/user_callouts';
+const featureId = 'web_ide_alert_dismissed';
+const editPath = 'edit/master/-/.gitlab-ci.yml';
+
+describe('WebIdeAlert', () => {
+ let wrapper;
+ let mock;
+
+ const findButton = () => wrapper.find(GlButton);
+ const findAlert = () => wrapper.find(GlAlert);
+ const dismissAlert = alertWrapper => alertWrapper.vm.$emit('dismiss');
+ const getPostPayload = () => JSON.parse(mock.history.post[0].data);
+
+ const createComponent = () => {
+ wrapper = shallowMount(WebIdeAlert, {
+ propsData: {
+ dismissEndpoint,
+ featureId,
+ editPath,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ mock.onPost(dismissEndpoint).reply(200);
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ mock.restore();
+ });
+
+ describe('with defaults', () => {
+ it('displays alert correctly', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('web ide button link has correct path', () => {
+ expect(findButton().attributes('href')).toBe(editPath);
+ });
+
+ it('dismisses alert correctly', async () => {
+ const alertWrapper = findAlert();
+
+ dismissAlert(alertWrapper);
+
+ await waitForPromises();
+
+ expect(alertWrapper.exists()).toBe(false);
+ expect(mock.history.post).toHaveLength(1);
+ expect(getPostPayload()).toEqual({ feature_name: featureId });
+ });
+ });
+});
diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js
index 98fa96de124..a105b62586b 100644
--- a/spec/frontend/blob_edit/blob_bundle_spec.js
+++ b/spec/frontend/blob_edit/blob_bundle_spec.js
@@ -43,7 +43,8 @@ describe('BlobBundle', () => {
data-target="#target"
data-track-label="suggest_gitlab_ci_yml"
data-dismiss-key="1"
- data-human-access="owner">
+ data-human-access="owner"
+ data-merge-request-path="path/to/mr">
<button id='commit-changes' class="js-commit-button"></button>
<a class="btn btn-cancel" href="#"></a>
</div>
diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js
index 9642b55b9b4..8f92e8498b9 100644
--- a/spec/frontend/blob_edit/edit_blob_spec.js
+++ b/spec/frontend/blob_edit/edit_blob_spec.js
@@ -1,15 +1,18 @@
import EditBlob from '~/blob_edit/edit_blob';
import EditorLite from '~/editor/editor_lite';
import MarkdownExtension from '~/editor/editor_markdown_ext';
+import FileTemplateExtension from '~/editor/editor_file_template_ext';
jest.mock('~/editor/editor_lite');
jest.mock('~/editor/editor_markdown_ext');
describe('Blob Editing', () => {
+ const mockInstance = 'foo';
beforeEach(() => {
setFixtures(
- `<div class="js-edit-blob-form"><div id="file_path"></div><div id="iditor"></div><input id="file-content"></div>`,
+ `<div class="js-edit-blob-form"><div id="file_path"></div><div id="editor"></div><input id="file-content"></div>`,
);
+ jest.spyOn(EditorLite.prototype, 'createInstance').mockReturnValue(mockInstance);
});
const initEditor = (isMarkdown = false) => {
@@ -19,13 +22,29 @@ describe('Blob Editing', () => {
});
};
- it('does not load MarkdownExtension by default', async () => {
+ it('loads FileTemplateExtension by default', async () => {
await initEditor();
- expect(EditorLite.prototype.use).not.toHaveBeenCalled();
+ expect(EditorLite.prototype.use).toHaveBeenCalledWith(
+ expect.arrayContaining([FileTemplateExtension]),
+ mockInstance,
+ );
});
- it('loads MarkdownExtension only for the markdown files', async () => {
- await initEditor(true);
- expect(EditorLite.prototype.use).toHaveBeenCalledWith(MarkdownExtension);
+ describe('Markdown', () => {
+ it('does not load MarkdownExtension by default', async () => {
+ await initEditor();
+ expect(EditorLite.prototype.use).not.toHaveBeenCalledWith(
+ expect.arrayContaining([MarkdownExtension]),
+ mockInstance,
+ );
+ });
+
+ it('loads MarkdownExtension only for the markdown files', async () => {
+ await initEditor(true);
+ expect(EditorLite.prototype.use).toHaveBeenCalledWith(
+ [MarkdownExtension, FileTemplateExtension],
+ mockInstance,
+ );
+ });
});
});
diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js
index b51a82f2a35..80d7a72151d 100644
--- a/spec/frontend/boards/board_list_helper.js
+++ b/spec/frontend/boards/board_list_helper.js
@@ -52,10 +52,12 @@ export default function createComponent({
list,
issues: list.issues,
loading: false,
- issueLinkBase: '/issues',
- rootPath: '/',
...componentProps,
},
+ provide: {
+ groupId: null,
+ rootPath: '/',
+ },
}).$mount();
Vue.nextTick(() => {
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index 3a64b004847..88883ae61d4 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -45,10 +45,12 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP
list,
issues: list.issues,
loading: false,
- issueLinkBase: '/issues',
- rootPath: '/',
...componentProps,
},
+ provide: {
+ groupId: null,
+ rootPath: '/',
+ },
}).$mount();
Vue.nextTick(() => {
diff --git a/spec/frontend/boards/board_new_issue_spec.js b/spec/frontend/boards/board_new_issue_spec.js
index 94afc8a2b45..3eebfeca965 100644
--- a/spec/frontend/boards/board_new_issue_spec.js
+++ b/spec/frontend/boards/board_new_issue_spec.js
@@ -1,6 +1,7 @@
/* global List */
import Vue from 'vue';
+import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import boardNewIssue from '~/boards/components/board_new_issue.vue';
@@ -10,6 +11,7 @@ import '~/boards/models/list';
import { listObj, boardsMockInterceptor } from './mock_data';
describe('Issue boards new issue form', () => {
+ let wrapper;
let vm;
let list;
let mock;
@@ -24,13 +26,11 @@ describe('Issue boards new issue form', () => {
const dummySubmitEvent = {
preventDefault() {},
};
- vm.$refs.submitButton = vm.$el.querySelector('.btn-success');
- return vm.submit(dummySubmitEvent);
+ wrapper.vm.$refs.submitButton = wrapper.find({ ref: 'submitButton' });
+ return wrapper.vm.submit(dummySubmitEvent);
};
beforeEach(() => {
- setFixtures('<div class="test-container"></div>');
-
const BoardNewIssueComp = Vue.extend(boardNewIssue);
mock = new MockAdapter(axios);
@@ -43,46 +43,52 @@ describe('Issue boards new issue form', () => {
newIssueMock = Promise.resolve(promiseReturn);
jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock);
- vm = new BoardNewIssueComp({
+ wrapper = mount(BoardNewIssueComp, {
propsData: {
+ disabled: false,
list,
},
- }).$mount(document.querySelector('.test-container'));
+ provide: {
+ groupId: null,
+ },
+ });
+
+ vm = wrapper.vm;
return Vue.nextTick();
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
mock.restore();
});
it('calls submit if submit button is clicked', () => {
- jest.spyOn(vm, 'submit').mockImplementation(e => e.preventDefault());
+ jest.spyOn(wrapper.vm, 'submit').mockImplementation();
vm.title = 'Testing Title';
- return Vue.nextTick().then(() => {
- vm.$el.querySelector('.btn-success').click();
-
- expect(vm.submit.mock.calls.length).toBe(1);
- });
+ return Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
+ expect(wrapper.vm.submit).toHaveBeenCalled();
+ });
});
it('disables submit button if title is empty', () => {
- expect(vm.$el.querySelector('.btn-success').disabled).toBe(true);
+ expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(true);
});
it('enables submit button if title is not empty', () => {
- vm.title = 'Testing Title';
+ wrapper.setData({ title: 'Testing Title' });
return Vue.nextTick().then(() => {
- expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title');
- expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true);
+ expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title');
+ expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(false);
});
});
it('clears title after clicking cancel', () => {
- vm.$el.querySelector('.btn-default').click();
+ wrapper.find({ ref: 'cancelButton' }).trigger('click');
return Vue.nextTick().then(() => {
expect(vm.title).toBe('');
@@ -97,7 +103,7 @@ describe('Issue boards new issue form', () => {
describe('submit success', () => {
it('creates new issue', () => {
- vm.title = 'submit title';
+ wrapper.setData({ title: 'submit issue' });
return Vue.nextTick()
.then(submitIssue)
@@ -107,17 +113,18 @@ describe('Issue boards new issue form', () => {
});
it('enables button after submit', () => {
- vm.title = 'submit issue';
+ jest.spyOn(wrapper.vm, 'submit').mockImplementation();
+ wrapper.setData({ title: 'submit issue' });
return Vue.nextTick()
.then(submitIssue)
.then(() => {
- expect(vm.$el.querySelector('.btn-success').disabled).toBe(false);
+ expect(wrapper.vm.$refs.submitButton.props().disabled).toBe(false);
});
});
it('clears title after submit', () => {
- vm.title = 'submit issue';
+ wrapper.setData({ title: 'submit issue' });
return Vue.nextTick()
.then(submitIssue)
@@ -128,7 +135,7 @@ describe('Issue boards new issue form', () => {
it('sets detail issue after submit', () => {
expect(boardsStore.detail.issue.title).toBe(undefined);
- vm.title = 'submit issue';
+ wrapper.setData({ title: 'submit issue' });
return Vue.nextTick()
.then(submitIssue)
@@ -138,7 +145,7 @@ describe('Issue boards new issue form', () => {
});
it('sets detail list after submit', () => {
- vm.title = 'submit issue';
+ wrapper.setData({ title: 'submit issue' });
return Vue.nextTick()
.then(submitIssue)
@@ -149,7 +156,7 @@ describe('Issue boards new issue form', () => {
it('sets detail weight after submit', () => {
boardsStore.weightFeatureAvailable = true;
- vm.title = 'submit issue';
+ wrapper.setData({ title: 'submit issue' });
return Vue.nextTick()
.then(submitIssue)
@@ -160,7 +167,7 @@ describe('Issue boards new issue form', () => {
it('does not set detail weight after submit', () => {
boardsStore.weightFeatureAvailable = false;
- vm.title = 'submit issue';
+ wrapper.setData({ title: 'submit issue' });
return Vue.nextTick()
.then(submitIssue)
diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js
index 29cc8f981bd..41971137b95 100644
--- a/spec/frontend/boards/boards_store_spec.js
+++ b/spec/frontend/boards/boards_store_spec.js
@@ -312,7 +312,7 @@ describe('boardsStore', () => {
});
describe('newIssue', () => {
- const id = 'not-creative';
+ const id = 1;
const issue = { some: 'issue data' };
const url = `${endpoints.listsEndpoint}/${id}/issues`;
const expectedRequest = expect.objectContaining({
diff --git a/spec/frontend/boards/components/board_card_layout_spec.js b/spec/frontend/boards/components/board_card_layout_spec.js
new file mode 100644
index 00000000000..80f649a1a96
--- /dev/null
+++ b/spec/frontend/boards/components/board_card_layout_spec.js
@@ -0,0 +1,95 @@
+/* global List */
+/* global ListLabel */
+
+import { shallowMount } from '@vue/test-utils';
+
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+
+import '~/boards/models/label';
+import '~/boards/models/assignee';
+import '~/boards/models/list';
+import store from '~/boards/stores';
+import boardsStore from '~/boards/stores/boards_store';
+import BoardCardLayout from '~/boards/components/board_card_layout.vue';
+import issueCardInner from '~/boards/components/issue_card_inner.vue';
+import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
+
+describe('Board card layout', () => {
+ let wrapper;
+ let mock;
+ let list;
+
+ // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
+ const mountComponent = propsData => {
+ wrapper = shallowMount(BoardCardLayout, {
+ stubs: {
+ issueCardInner,
+ },
+ store,
+ propsData: {
+ list,
+ issue: list.issues[0],
+ disabled: false,
+ index: 0,
+ ...propsData,
+ },
+ provide: {
+ groupId: null,
+ rootPath: '/',
+ },
+ });
+ };
+
+ const setupData = () => {
+ list = new List(listObj);
+ boardsStore.create();
+ boardsStore.detail.issue = {};
+ const label1 = new ListLabel({
+ id: 3,
+ title: 'testing 123',
+ color: '#000cff',
+ text_color: 'white',
+ description: 'test',
+ });
+ return waitForPromises().then(() => {
+ list.issues[0].labels.push(label1);
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onAny().reply(boardsMockInterceptor);
+ setMockEndpoints();
+ return setupData();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ list = null;
+ mock.restore();
+ });
+
+ describe('mouse events', () => {
+ it('sets showDetail to true on mousedown', async () => {
+ mountComponent();
+
+ wrapper.trigger('mousedown');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.showDetail).toBe(true);
+ });
+
+ it('sets showDetail to false on mousemove', async () => {
+ mountComponent();
+ wrapper.trigger('mousedown');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.showDetail).toBe(true);
+ wrapper.trigger('mousemove');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.showDetail).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/boards/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index d01b895f996..a3ddcdf01b7 100644
--- a/spec/frontend/boards/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -2,7 +2,7 @@
/* global ListAssignee */
/* global ListLabel */
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
@@ -15,12 +15,12 @@ import '~/boards/models/assignee';
import '~/boards/models/list';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
-import boardCard from '~/boards/components/board_card.vue';
+import BoardCard from '~/boards/components/board_card.vue';
import issueCardInner from '~/boards/components/issue_card_inner.vue';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { listObj, boardsMockInterceptor, setMockEndpoints } from './mock_data';
+import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
-describe('Board card', () => {
+describe('BoardCard', () => {
let wrapper;
let mock;
let list;
@@ -30,7 +30,7 @@ describe('Board card', () => {
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
const mountComponent = propsData => {
- wrapper = shallowMount(boardCard, {
+ wrapper = mount(BoardCard, {
stubs: {
issueCardInner,
},
@@ -38,16 +38,18 @@ describe('Board card', () => {
propsData: {
list,
issue: list.issues[0],
- issueLinkBase: '/',
disabled: false,
index: 0,
- rootPath: '/',
...propsData,
},
+ provide: {
+ groupId: null,
+ rootPath: '/',
+ },
});
};
- const setupData = () => {
+ const setupData = async () => {
list = new List(listObj);
boardsStore.create();
boardsStore.detail.issue = {};
@@ -58,9 +60,9 @@ describe('Board card', () => {
text_color: 'white',
description: 'test',
});
- return waitForPromises().then(() => {
- list.issues[0].labels.push(label1);
- });
+ await waitForPromises();
+
+ list.issues[0].labels.push(label1);
};
beforeEach(() => {
@@ -79,7 +81,7 @@ describe('Board card', () => {
it('when details issue is empty does not show the element', () => {
mountComponent();
- expect(wrapper.classes()).not.toContain('is-active');
+ expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active');
});
it('when detailIssue is equal to card issue shows the element', () => {
@@ -124,29 +126,6 @@ describe('Board card', () => {
});
describe('mouse events', () => {
- it('sets showDetail to true on mousedown', () => {
- mountComponent();
- wrapper.trigger('mousedown');
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.showDetail).toBe(true);
- });
- });
-
- it('sets showDetail to false on mousemove', () => {
- mountComponent();
- wrapper.trigger('mousedown');
- return wrapper.vm
- .$nextTick()
- .then(() => {
- expect(wrapper.vm.showDetail).toBe(true);
- wrapper.trigger('mousemove');
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(wrapper.vm.showDetail).toBe(false);
- });
- });
-
it('does not set detail issue if showDetail is false', () => {
mountComponent();
expect(boardsStore.detail.issue).toEqual({});
@@ -219,6 +198,9 @@ describe('Board card', () => {
boardsStore.detail.issue = {};
mountComponent();
+ // sets conditional so that event is emitted.
+ wrapper.trigger('mousedown');
+
wrapper.trigger('mouseup');
expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll');
diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js
index c06b7aceaad..2a4dbbb989e 100644
--- a/spec/frontend/boards/components/board_column_spec.js
+++ b/spec/frontend/boards/components/board_column_spec.js
@@ -59,10 +59,11 @@ describe('Board Column Component', () => {
propsData: {
boardId,
disabled: false,
- issueLinkBase: '/',
- rootPath: '/',
list,
},
+ provide: {
+ boardId,
+ },
});
};
diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js
new file mode 100644
index 00000000000..df117d06cdf
--- /dev/null
+++ b/spec/frontend/boards/components/board_content_spec.js
@@ -0,0 +1,64 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
+import BoardColumn from 'ee_else_ce/boards/components/board_column.vue';
+import getters from 'ee_else_ce/boards/stores/getters';
+import { mockListsWithModel } from '../mock_data';
+import BoardContent from '~/boards/components/board_content.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('BoardContent', () => {
+ let wrapper;
+
+ const defaultState = {
+ isShowingEpicsSwimlanes: false,
+ boardLists: mockListsWithModel,
+ error: undefined,
+ };
+
+ const createStore = (state = defaultState) => {
+ return new Vuex.Store({
+ getters,
+ state,
+ actions: {
+ fetchIssuesForAllLists: () => {},
+ },
+ });
+ };
+
+ const createComponent = state => {
+ const store = createStore({
+ ...defaultState,
+ ...state,
+ });
+ wrapper = shallowMount(BoardContent, {
+ localVue,
+ propsData: {
+ lists: mockListsWithModel,
+ canAdminList: true,
+ disabled: false,
+ },
+ store,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a BoardColumn component per list', () => {
+ expect(wrapper.findAll(BoardColumn)).toHaveLength(mockListsWithModel.length);
+ });
+
+ it('does not display EpicsSwimlanes component', () => {
+ expect(wrapper.find(EpicsSwimlanes).exists()).toBe(false);
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js
index b1d277863e8..65d8070192c 100644
--- a/spec/frontend/boards/components/board_form_spec.js
+++ b/spec/frontend/boards/components/board_form_spec.js
@@ -11,7 +11,7 @@ describe('board_form.vue', () => {
const propsData = {
canAdminBoard: false,
labelsPath: `${TEST_HOST}/labels/path`,
- milestonePath: `${TEST_HOST}/milestone/path`,
+ labelsWebUrl: `${TEST_HOST}/-/labels`,
};
const findModal = () => wrapper.find(DeprecatedModal);
diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js
index 76a3d5e71c8..2439c347bf0 100644
--- a/spec/frontend/boards/components/board_list_header_spec.js
+++ b/spec/frontend/boards/components/board_list_header_spec.js
@@ -57,12 +57,12 @@ describe('Board List Header Component', () => {
wrapper = shallowMount(BoardListHeader, {
propsData: {
- boardId,
disabled: false,
- issueLinkBase: '/',
- rootPath: '/',
list,
},
+ provide: {
+ boardId,
+ },
});
};
@@ -106,7 +106,7 @@ describe('Board List Header Component', () => {
createComponent();
expect(isCollapsed()).toBe(false);
- wrapper.find('[data-testid="board-list-header"]').vm.$emit('click');
+ wrapper.find('[data-testid="board-list-header"]').trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(false);
diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js
index f39adc0fc49..12c9431f2d4 100644
--- a/spec/frontend/boards/components/board_settings_sidebar_spec.js
+++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js
@@ -6,8 +6,9 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlDrawer, GlLabel } from '@gitlab/ui';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
import boardsStore from '~/boards/stores/boards_store';
+import { createStore } from '~/boards/stores';
import sidebarEventHub from '~/sidebar/event_hub';
-import { inactiveId } from '~/boards/constants';
+import { inactiveId, LIST } from '~/boards/constants';
const localVue = createLocalVue();
@@ -16,19 +17,12 @@ localVue.use(Vuex);
describe('BoardSettingsSidebar', () => {
let wrapper;
let mock;
- let storeActions;
+ let store;
const labelTitle = 'test';
const labelColor = '#FFFF';
const listId = 1;
- const createComponent = (state = { activeId: inactiveId }, actions = {}) => {
- storeActions = actions;
-
- const store = new Vuex.Store({
- state,
- actions: storeActions,
- });
-
+ const createComponent = () => {
wrapper = shallowMount(BoardSettingsSidebar, {
store,
localVue,
@@ -38,6 +32,9 @@ describe('BoardSettingsSidebar', () => {
const findDrawer = () => wrapper.find(GlDrawer);
beforeEach(() => {
+ store = createStore();
+ store.state.activeId = inactiveId;
+ store.state.sidebarType = LIST;
boardsStore.create();
});
@@ -46,114 +43,125 @@ describe('BoardSettingsSidebar', () => {
wrapper.destroy();
});
- it('finds a GlDrawer component', () => {
- createComponent();
+ describe('when sidebarType is "list"', () => {
+ it('finds a GlDrawer component', () => {
+ createComponent();
- expect(findDrawer().exists()).toBe(true);
- });
+ expect(findDrawer().exists()).toBe(true);
+ });
- describe('on close', () => {
- it('calls closeSidebar', async () => {
- const spy = jest.fn();
- createComponent({ activeId: inactiveId }, { setActiveId: spy });
+ describe('on close', () => {
+ it('closes the sidebar', async () => {
+ createComponent();
- findDrawer().vm.$emit('close');
+ findDrawer().vm.$emit('close');
- await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
- expect(storeActions.setActiveId).toHaveBeenCalledWith(
- expect.anything(),
- inactiveId,
- undefined,
- );
- });
+ expect(wrapper.find(GlDrawer).exists()).toBe(false);
+ });
- it('calls closeSidebar on sidebar.closeAll event', async () => {
- createComponent({ activeId: inactiveId }, { setActiveId: jest.fn() });
+ it('closes the sidebar when emitting the correct event', async () => {
+ createComponent();
- sidebarEventHub.$emit('sidebar.closeAll');
+ sidebarEventHub.$emit('sidebar.closeAll');
- await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
- expect(storeActions.setActiveId).toHaveBeenCalledWith(
- expect.anything(),
- inactiveId,
- undefined,
- );
+ expect(wrapper.find(GlDrawer).exists()).toBe(false);
+ });
});
- });
- describe('when activeId is zero', () => {
- it('renders GlDrawer with open false', () => {
- createComponent();
+ describe('when activeId is zero', () => {
+ it('renders GlDrawer with open false', () => {
+ createComponent();
- expect(findDrawer().props('open')).toBe(false);
+ expect(findDrawer().props('open')).toBe(false);
+ });
});
- });
- describe('when activeId is greater than zero', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
+ describe('when activeId is greater than zero', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ boardsStore.addList({
+ id: listId,
+ label: { title: labelTitle, color: labelColor },
+ list_type: 'label',
+ });
+ store.state.activeId = 1;
+ store.state.sidebarType = LIST;
+ });
- boardsStore.addList({
- id: listId,
- label: { title: labelTitle, color: labelColor },
- list_type: 'label',
+ afterEach(() => {
+ boardsStore.removeList(listId);
});
- });
- afterEach(() => {
- boardsStore.removeList(listId);
+ it('renders GlDrawer with open false', () => {
+ createComponent();
+
+ expect(findDrawer().props('open')).toBe(true);
+ });
});
- it('renders GlDrawer with open false', () => {
- createComponent({ activeId: 1 });
+ describe('when activeId is in boardsStore', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
- expect(findDrawer().props('open')).toBe(true);
- });
- });
+ boardsStore.addList({
+ id: listId,
+ label: { title: labelTitle, color: labelColor },
+ list_type: 'label',
+ });
- describe('when activeId is in boardsStore', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
+ store.state.activeId = listId;
+ store.state.sidebarType = LIST;
- boardsStore.addList({
- id: listId,
- label: { title: labelTitle, color: labelColor },
- list_type: 'label',
+ createComponent();
});
- createComponent({ activeId: listId });
- });
+ afterEach(() => {
+ mock.restore();
+ });
- afterEach(() => {
- mock.restore();
- });
+ it('renders label title', () => {
+ expect(findLabel().props('title')).toBe(labelTitle);
+ });
- it('renders label title', () => {
- expect(findLabel().props('title')).toBe(labelTitle);
+ it('renders label background color', () => {
+ expect(findLabel().props('backgroundColor')).toBe(labelColor);
+ });
});
- it('renders label background color', () => {
- expect(findLabel().props('backgroundColor')).toBe(labelColor);
- });
- });
+ describe('when activeId is not in boardsStore', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
- describe('when activeId is not in boardsStore', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
+ boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } });
+
+ store.state.activeId = inactiveId;
- boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } });
+ createComponent();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
- createComponent({ activeId: inactiveId });
+ it('does not render GlLabel', () => {
+ expect(findLabel().exists()).toBe(false);
+ });
});
+ });
- afterEach(() => {
- mock.restore();
+ describe('when sidebarType is not List', () => {
+ beforeEach(() => {
+ store.state.sidebarType = '';
+ createComponent();
});
- it('does not render GlLabel', () => {
- expect(findLabel().exists()).toBe(false);
+ it('does not render GlDrawer', () => {
+ expect(findDrawer().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js
index f2d4de238d1..2b7605a3f7c 100644
--- a/spec/frontend/boards/components/boards_selector_spec.js
+++ b/spec/frontend/boards/components/boards_selector_spec.js
@@ -81,12 +81,12 @@ describe('BoardsSelector', () => {
assignee_id: null,
labels: [],
},
- milestonePath: `${TEST_HOST}/milestone/path`,
boardBaseUrl: `${TEST_HOST}/board/base/url`,
hasMissingBoards: false,
canAdminBoard: true,
multipleIssueBoardsAvailable: true,
labelsPath: `${TEST_HOST}/labels/path`,
+ labelsWebUrl: `${TEST_HOST}/labels`,
projectId: 42,
groupId: 19,
scopedIssueBoardFeatureEnabled: true,
diff --git a/spec/frontend/boards/components/issuable_title_spec.js b/spec/frontend/boards/components/issuable_title_spec.js
new file mode 100644
index 00000000000..4b7f491b998
--- /dev/null
+++ b/spec/frontend/boards/components/issuable_title_spec.js
@@ -0,0 +1,33 @@
+import { shallowMount } from '@vue/test-utils';
+import IssuableTitle from '~/boards/components/issuable_title.vue';
+
+describe('IssuableTitle', () => {
+ let wrapper;
+ const defaultProps = {
+ title: 'One',
+ refPath: 'path',
+ };
+ const createComponent = () => {
+ wrapper = shallowMount(IssuableTitle, {
+ propsData: { ...defaultProps },
+ });
+ };
+ const findIssueContent = () => wrapper.find('[data-testid="issue-title"]');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders a title of an issue in the sidebar', () => {
+ expect(findIssueContent().text()).toContain('One');
+ });
+
+ it('renders a referencePath of an issue in the sidebar', () => {
+ expect(findIssueContent().text()).toContain('path');
+ });
+});
diff --git a/spec/frontend/boards/components/issue_count_spec.js b/spec/frontend/boards/components/issue_count_spec.js
index 819d878f4e2..d1ff0bdbf88 100644
--- a/spec/frontend/boards/components/issue_count_spec.js
+++ b/spec/frontend/boards/components/issue_count_spec.js
@@ -29,7 +29,7 @@ describe('IssueCount', () => {
});
it('does not contains maxIssueCount in the template', () => {
- expect(vm.contains('.js-max-issue-size')).toBe(false);
+ expect(vm.find('.js-max-issue-size').exists()).toBe(false);
});
});
diff --git a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
new file mode 100644
index 00000000000..1dbcbd06407
--- /dev/null
+++ b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
@@ -0,0 +1,107 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import BoardSidebarItem from '~/boards/components/sidebar/board_editable_item.vue';
+
+describe('boards sidebar remove issue', () => {
+ let wrapper;
+
+ const findLoader = () => wrapper.find(GlLoadingIcon);
+ const findEditButton = () => wrapper.find('[data-testid="edit-button"]');
+ const findTitle = () => wrapper.find('[data-testid="title"]');
+ const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
+ const findExpanded = () => wrapper.find('[data-testid="expanded-content"]');
+
+ const createComponent = ({ props = {}, slots = {}, canUpdate = false } = {}) => {
+ wrapper = shallowMount(BoardSidebarItem, {
+ attachTo: document.body,
+ provide: { canUpdate },
+ propsData: props,
+ slots,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('template', () => {
+ it('renders title', () => {
+ const title = 'Sidebar item title';
+ createComponent({ props: { title } });
+
+ expect(findTitle().text()).toBe(title);
+ });
+
+ it('hides edit button, loader and expanded content by default', () => {
+ createComponent();
+
+ expect(findEditButton().exists()).toBe(false);
+ expect(findLoader().exists()).toBe(false);
+ expect(findExpanded().isVisible()).toBe(false);
+ });
+
+ it('shows "None" if empty collapsed slot', () => {
+ createComponent({});
+
+ expect(findCollapsed().text()).toBe('None');
+ });
+
+ it('renders collapsed content by default', () => {
+ const slots = { collapsed: '<div>Collapsed content</div>' };
+ createComponent({ slots });
+
+ expect(findCollapsed().text()).toBe('Collapsed content');
+ });
+
+ it('shows edit button if can update', () => {
+ createComponent({ canUpdate: true });
+
+ expect(findEditButton().exists()).toBe(true);
+ });
+
+ it('shows loading icon if loading', () => {
+ createComponent({ props: { loading: true } });
+
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('shows expanded content and hides collapsed content when clicking edit button', async () => {
+ const slots = { default: '<div>Select item</div>' };
+ createComponent({ canUpdate: true, slots });
+ findEditButton().vm.$emit('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findCollapsed().isVisible()).toBe(false);
+ expect(findExpanded().isVisible()).toBe(true);
+ expect(findExpanded().text()).toBe('Select item');
+ });
+ });
+ });
+
+ describe('collapsing an item by offclicking', () => {
+ beforeEach(async () => {
+ createComponent({ canUpdate: true });
+ findEditButton().vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ });
+
+ it('hides expanded section and displays collapsed section', async () => {
+ expect(findExpanded().isVisible()).toBe(true);
+ document.body.click();
+
+ await wrapper.vm.$nextTick();
+
+ expect(findCollapsed().isVisible()).toBe(true);
+ expect(findExpanded().isVisible()).toBe(false);
+ });
+
+ it('emits changed event', async () => {
+ document.body.click();
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted().changed[1][0]).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_spec.js
index dee8cb7b6e5..7e22e9647f0 100644
--- a/spec/frontend/boards/issue_card_spec.js
+++ b/spec/frontend/boards/issue_card_spec.js
@@ -47,13 +47,15 @@ describe('Issue card component', () => {
propsData: {
list,
issue,
- issueLinkBase: '/test',
- rootPath: '/',
},
store,
stubs: {
GlLabel: true,
},
+ provide: {
+ groupId: null,
+ rootPath: '/',
+ },
});
});
diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js
index b731bb6e474..9c3a6e66ef4 100644
--- a/spec/frontend/boards/list_spec.js
+++ b/spec/frontend/boards/list_spec.js
@@ -184,6 +184,7 @@ describe('List model', () => {
}),
);
list.issues = [];
+ global.gon.features = { boardsWithSwimlanes: false };
});
it('adds new issue to top of list', done => {
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 8ef6efe23c7..5776332c499 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -1,3 +1,9 @@
+/* global ListIssue */
+/* global List */
+
+import Vue from 'vue';
+import '~/boards/models/list';
+import '~/boards/models/issue';
import boardsStore from '~/boards/stores/boards_store';
export const boardObj = {
@@ -92,11 +98,64 @@ export const mockMilestone = {
due_date: '2019-12-31',
};
+const assignees = [
+ {
+ id: 'gid://gitlab/User/2',
+ username: 'angelina.herman',
+ name: 'Bernardina Bosco',
+ avatar: 'https://www.gravatar.com/avatar/eb7b664b13a30ad9f9ba4b61d7075470?s=80&d=identicon',
+ webUrl: 'http://127.0.0.1:3000/angelina.herman',
+ },
+];
+
+const labels = [
+ {
+ id: 'gid://gitlab/GroupLabel/5',
+ title: 'Cosync',
+ color: '#34ebec',
+ description: null,
+ },
+];
+
+export const rawIssue = {
+ title: 'Issue 1',
+ id: 'gid://gitlab/Issue/436',
+ iid: 27,
+ dueDate: null,
+ timeEstimate: 0,
+ weight: null,
+ confidential: false,
+ referencePath: 'gitlab-org/test-subgroup/gitlab-test#27',
+ path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/27',
+ labels: {
+ nodes: [
+ {
+ id: 1,
+ title: 'test',
+ color: 'red',
+ description: 'testing',
+ },
+ ],
+ },
+ assignees: {
+ nodes: assignees,
+ },
+ epic: {
+ id: 'gid://gitlab/Epic/41',
+ },
+};
+
export const mockIssue = {
- title: 'Testing',
- id: 1,
- iid: 1,
+ id: 'gid://gitlab/Issue/436',
+ iid: 27,
+ title: 'Issue 1',
+ dueDate: null,
+ timeEstimate: 0,
+ weight: null,
confidential: false,
+ referencePath: 'gitlab-org/test-subgroup/gitlab-test#27',
+ path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/27',
+ assignees,
labels: [
{
id: 1,
@@ -105,16 +164,64 @@ export const mockIssue = {
description: 'testing',
},
],
- assignees: [
- {
- id: 1,
- name: 'name',
- username: 'username',
- avatar_url: 'http://avatar_url',
- },
- ],
+ epic: {
+ id: 'gid://gitlab/Epic/41',
+ },
};
+export const mockIssueWithModel = new ListIssue(mockIssue);
+
+export const mockIssue2 = {
+ id: 'gid://gitlab/Issue/437',
+ iid: 28,
+ title: 'Issue 2',
+ dueDate: null,
+ timeEstimate: 0,
+ weight: null,
+ confidential: false,
+ referencePath: 'gitlab-org/test-subgroup/gitlab-test#28',
+ path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/28',
+ assignees,
+ labels,
+ epic: {
+ id: 'gid://gitlab/Epic/40',
+ },
+};
+
+export const mockIssue2WithModel = new ListIssue(mockIssue2);
+
+export const mockIssue3 = {
+ id: 'gid://gitlab/Issue/438',
+ iid: 29,
+ title: 'Issue 3',
+ referencePath: '#29',
+ dueDate: null,
+ timeEstimate: 0,
+ weight: null,
+ confidential: false,
+ path: '/gitlab-org/gitlab-test/-/issues/28',
+ assignees,
+ labels,
+ epic: null,
+};
+
+export const mockIssue4 = {
+ id: 'gid://gitlab/Issue/439',
+ iid: 30,
+ title: 'Issue 4',
+ referencePath: '#30',
+ dueDate: null,
+ timeEstimate: 0,
+ weight: null,
+ confidential: false,
+ path: '/gitlab-org/gitlab-test/-/issues/28',
+ assignees,
+ labels,
+ epic: null,
+};
+
+export const mockIssues = [mockIssue, mockIssue2];
+
export const BoardsMockData = {
GET: {
'/test/-/boards/1/lists/300/issues?id=300&page=1': {
@@ -165,3 +272,50 @@ export const setMockEndpoints = (opts = {}) => {
boardId,
});
};
+
+export const mockLists = [
+ {
+ id: 'gid://gitlab/List/1',
+ title: 'Backlog',
+ position: null,
+ listType: 'backlog',
+ collapsed: false,
+ label: null,
+ assignee: null,
+ milestone: null,
+ loading: false,
+ },
+ {
+ id: 'gid://gitlab/List/2',
+ title: 'To Do',
+ position: 0,
+ listType: 'label',
+ collapsed: false,
+ label: {
+ id: 'gid://gitlab/GroupLabel/121',
+ title: 'To Do',
+ color: '#F0AD4E',
+ textColor: '#FFFFFF',
+ description: null,
+ },
+ assignee: null,
+ milestone: null,
+ loading: false,
+ },
+];
+
+export const mockListsWithModel = mockLists.map(listMock =>
+ Vue.observable(new List({ ...listMock, doNotFetchIssues: true })),
+);
+
+export const mockIssuesByListId = {
+ 'gid://gitlab/List/1': [mockIssue.id, mockIssue3.id, mockIssue4.id],
+ 'gid://gitlab/List/2': mockIssues.map(({ id }) => id),
+};
+
+export const issues = {
+ [mockIssue.id]: mockIssue,
+ [mockIssue2.id]: mockIssue2,
+ [mockIssue3.id]: mockIssue3,
+ [mockIssue4.id]: mockIssue4,
+};
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index d539cba76ca..bdbcd435708 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -1,7 +1,17 @@
import testAction from 'helpers/vuex_action_helper';
-import actions from '~/boards/stores/actions';
+import {
+ mockListsWithModel,
+ mockLists,
+ mockIssue,
+ mockIssueWithModel,
+ mockIssue2WithModel,
+ rawIssue,
+} from '../mock_data';
+import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
-import { inactiveId } from '~/boards/constants';
+import { inactiveId, ListType } from '~/boards/constants';
+import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql';
+import { fullBoardId } from '~/boards/boards_util';
const expectNotImplemented = action => {
it('is not implemented', () => {
@@ -9,6 +19,10 @@ const expectNotImplemented = action => {
});
};
+// We need this helper to make sure projectPath is including
+// subgroups when the movIssue action is called.
+const getProjectPath = path => path.split('#')[0];
+
describe('setInitialBoardData', () => {
it('sets data object', () => {
const mockData = {
@@ -26,6 +40,25 @@ describe('setInitialBoardData', () => {
});
});
+describe('setFilters', () => {
+ it('should commit mutation SET_FILTERS', done => {
+ const state = {
+ filters: {},
+ };
+
+ const filters = { labelName: 'label' };
+
+ testAction(
+ actions.setFilters,
+ filters,
+ state,
+ [{ type: types.SET_FILTERS, payload: filters }],
+ [],
+ done,
+ );
+ });
+});
+
describe('setActiveId', () => {
it('should commit mutation SET_ACTIVE_ID', done => {
const state = {
@@ -34,17 +67,40 @@ describe('setActiveId', () => {
testAction(
actions.setActiveId,
- 1,
+ { id: 1, sidebarType: 'something' },
state,
- [{ type: types.SET_ACTIVE_ID, payload: 1 }],
+ [{ type: types.SET_ACTIVE_ID, payload: { id: 1, sidebarType: 'something' } }],
[],
done,
);
});
});
-describe('fetchLists', () => {
- expectNotImplemented(actions.fetchLists);
+describe('showWelcomeList', () => {
+ it('should dispatch addList action', done => {
+ const state = {
+ endpoints: { fullPath: 'gitlab-org', boardId: '1' },
+ boardType: 'group',
+ disabled: false,
+ boardLists: [{ type: 'backlog' }, { type: 'closed' }],
+ };
+
+ const blankList = {
+ id: 'blank',
+ listType: ListType.blank,
+ title: 'Welcome to your issue board!',
+ position: 0,
+ };
+
+ testAction(
+ actions.showWelcomeList,
+ {},
+ state,
+ [],
+ [{ type: 'addList', payload: blankList }],
+ done,
+ );
+ });
});
describe('generateDefaultLists', () => {
@@ -52,29 +108,316 @@ describe('generateDefaultLists', () => {
});
describe('createList', () => {
- expectNotImplemented(actions.createList);
+ it('should dispatch addList action when creating backlog list', done => {
+ const backlogList = {
+ id: 'gid://gitlab/List/1',
+ listType: 'backlog',
+ title: 'Open',
+ position: 0,
+ };
+
+ jest.spyOn(gqlClient, 'mutate').mockReturnValue(
+ Promise.resolve({
+ data: {
+ boardListCreate: {
+ list: backlogList,
+ errors: [],
+ },
+ },
+ }),
+ );
+
+ const state = {
+ endpoints: { fullPath: 'gitlab-org', boardId: '1' },
+ boardType: 'group',
+ disabled: false,
+ boardLists: [{ type: 'closed' }],
+ };
+
+ testAction(
+ actions.createList,
+ { backlog: true },
+ state,
+ [],
+ [{ type: 'addList', payload: backlogList }],
+ done,
+ );
+ });
+
+ it('should commit CREATE_LIST_FAILURE mutation when API returns an error', done => {
+ jest.spyOn(gqlClient, 'mutate').mockReturnValue(
+ Promise.resolve({
+ data: {
+ boardListCreate: {
+ list: {},
+ errors: [{ foo: 'bar' }],
+ },
+ },
+ }),
+ );
+
+ const state = {
+ endpoints: { fullPath: 'gitlab-org', boardId: '1' },
+ boardType: 'group',
+ disabled: false,
+ boardLists: [{ type: 'closed' }],
+ };
+
+ testAction(
+ actions.createList,
+ { backlog: true },
+ state,
+ [{ type: types.CREATE_LIST_FAILURE }],
+ [],
+ done,
+ );
+ });
+});
+
+describe('moveList', () => {
+ it('should commit MOVE_LIST mutation and dispatch updateList action', done => {
+ const state = {
+ endpoints: { fullPath: 'gitlab-org', boardId: '1' },
+ boardType: 'group',
+ disabled: false,
+ boardLists: mockListsWithModel,
+ };
+
+ testAction(
+ actions.moveList,
+ { listId: 'gid://gitlab/List/1', newIndex: 1, adjustmentValue: 1 },
+ state,
+ [
+ {
+ type: types.MOVE_LIST,
+ payload: { movedList: mockListsWithModel[0], listAtNewIndex: mockListsWithModel[1] },
+ },
+ ],
+ [
+ {
+ type: 'updateList',
+ payload: { listId: 'gid://gitlab/List/1', position: 0, backupList: mockListsWithModel },
+ },
+ ],
+ done,
+ );
+ });
});
describe('updateList', () => {
- expectNotImplemented(actions.updateList);
+ it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ updateBoardList: {
+ list: {},
+ errors: [{ foo: 'bar' }],
+ },
+ },
+ });
+
+ const state = {
+ endpoints: { fullPath: 'gitlab-org', boardId: '1' },
+ boardType: 'group',
+ disabled: false,
+ boardLists: [{ type: 'closed' }],
+ };
+
+ testAction(
+ actions.updateList,
+ { listId: 'gid://gitlab/List/1', position: 1 },
+ state,
+ [{ type: types.UPDATE_LIST_FAILURE }],
+ [],
+ done,
+ );
+ });
});
describe('deleteList', () => {
expectNotImplemented(actions.deleteList);
});
-describe('fetchIssuesForList', () => {
- expectNotImplemented(actions.fetchIssuesForList);
-});
-
describe('moveIssue', () => {
- expectNotImplemented(actions.moveIssue);
+ const listIssues = {
+ 'gid://gitlab/List/1': [436, 437],
+ 'gid://gitlab/List/2': [],
+ };
+
+ const issues = {
+ '436': mockIssueWithModel,
+ '437': mockIssue2WithModel,
+ };
+
+ const state = {
+ endpoints: { fullPath: 'gitlab-org', boardId: '1' },
+ boardType: 'group',
+ disabled: false,
+ boardLists: mockListsWithModel,
+ issuesByListId: listIssues,
+ issues,
+ };
+
+ it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', done => {
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ issueMoveList: {
+ issue: rawIssue,
+ errors: [],
+ },
+ },
+ });
+
+ testAction(
+ actions.moveIssue,
+ {
+ issueId: '436',
+ issueIid: mockIssue.iid,
+ issuePath: mockIssue.referencePath,
+ fromListId: 'gid://gitlab/List/1',
+ toListId: 'gid://gitlab/List/2',
+ },
+ state,
+ [
+ {
+ type: types.MOVE_ISSUE,
+ payload: {
+ originalIssue: mockIssueWithModel,
+ fromListId: 'gid://gitlab/List/1',
+ toListId: 'gid://gitlab/List/2',
+ },
+ },
+ {
+ type: types.MOVE_ISSUE_SUCCESS,
+ payload: { issue: rawIssue },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('calls mutate with the correct variables', () => {
+ const mutationVariables = {
+ mutation: issueMoveListMutation,
+ variables: {
+ projectPath: getProjectPath(mockIssue.referencePath),
+ boardId: fullBoardId(state.endpoints.boardId),
+ iid: mockIssue.iid,
+ fromListId: 1,
+ toListId: 2,
+ moveBeforeId: undefined,
+ moveAfterId: undefined,
+ },
+ };
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ issueMoveList: {
+ issue: rawIssue,
+ errors: [],
+ },
+ },
+ });
+
+ actions.moveIssue(
+ { state, commit: () => {} },
+ {
+ issueId: mockIssue.id,
+ issueIid: mockIssue.iid,
+ issuePath: mockIssue.referencePath,
+ fromListId: 'gid://gitlab/List/1',
+ toListId: 'gid://gitlab/List/2',
+ },
+ );
+
+ expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
+ });
+
+ it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_FAILURE mutation when unsuccessful', done => {
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ issueMoveList: {
+ issue: {},
+ errors: [{ foo: 'bar' }],
+ },
+ },
+ });
+
+ testAction(
+ actions.moveIssue,
+ {
+ issueId: '436',
+ issueIid: mockIssue.iid,
+ issuePath: mockIssue.referencePath,
+ fromListId: 'gid://gitlab/List/1',
+ toListId: 'gid://gitlab/List/2',
+ },
+ state,
+ [
+ {
+ type: types.MOVE_ISSUE,
+ payload: {
+ originalIssue: mockIssueWithModel,
+ fromListId: 'gid://gitlab/List/1',
+ toListId: 'gid://gitlab/List/2',
+ },
+ },
+ {
+ type: types.MOVE_ISSUE_FAILURE,
+ payload: {
+ originalIssue: mockIssueWithModel,
+ fromListId: 'gid://gitlab/List/1',
+ toListId: 'gid://gitlab/List/2',
+ originalIndex: 0,
+ },
+ },
+ ],
+ [],
+ done,
+ );
+ });
});
describe('createNewIssue', () => {
expectNotImplemented(actions.createNewIssue);
});
+describe('addListIssue', () => {
+ it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
+ const payload = {
+ list: mockLists[0],
+ issue: mockIssue,
+ position: 0,
+ };
+
+ testAction(
+ actions.addListIssue,
+ payload,
+ {},
+ [{ type: types.ADD_ISSUE_TO_LIST, payload }],
+ [],
+ done,
+ );
+ });
+});
+
+describe('addListIssueFailure', () => {
+ it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
+ const payload = {
+ list: mockLists[0],
+ issue: mockIssue,
+ };
+
+ testAction(
+ actions.addListIssueFailure,
+ payload,
+ {},
+ [{ type: types.ADD_ISSUE_TO_LIST_FAILURE, payload }],
+ [],
+ done,
+ );
+ });
+});
+
describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog);
});
diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js
index 38b2333e679..288143a0f21 100644
--- a/spec/frontend/boards/stores/getters_spec.js
+++ b/spec/frontend/boards/stores/getters_spec.js
@@ -1,4 +1,6 @@
import getters from '~/boards/stores/getters';
+import { inactiveId } from '~/boards/constants';
+import { mockIssue, mockIssue2, mockIssues, mockIssuesByListId, issues } from '../mock_data';
describe('Boards - Getters', () => {
describe('getLabelToggleState', () => {
@@ -18,4 +20,114 @@ describe('Boards - Getters', () => {
expect(getters.getLabelToggleState(state)).toBe('off');
});
});
+
+ describe('isSidebarOpen', () => {
+ it('returns true when activeId is not equal to 0', () => {
+ const state = {
+ activeId: 1,
+ };
+
+ expect(getters.isSidebarOpen(state)).toBe(true);
+ });
+
+ it('returns false when activeId is equal to 0', () => {
+ const state = {
+ activeId: inactiveId,
+ };
+
+ expect(getters.isSidebarOpen(state)).toBe(false);
+ });
+ });
+
+ describe('isSwimlanesOn', () => {
+ afterEach(() => {
+ window.gon = { features: {} };
+ });
+
+ describe('when boardsWithSwimlanes is true', () => {
+ beforeEach(() => {
+ window.gon = { features: { boardsWithSwimlanes: true } };
+ });
+
+ describe('when isShowingEpicsSwimlanes is true', () => {
+ it('returns true', () => {
+ const state = {
+ isShowingEpicsSwimlanes: true,
+ };
+
+ expect(getters.isSwimlanesOn(state)).toBe(true);
+ });
+ });
+
+ describe('when isShowingEpicsSwimlanes is false', () => {
+ it('returns false', () => {
+ const state = {
+ isShowingEpicsSwimlanes: false,
+ };
+
+ expect(getters.isSwimlanesOn(state)).toBe(false);
+ });
+ });
+ });
+
+ describe('when boardsWithSwimlanes is false', () => {
+ describe('when isShowingEpicsSwimlanes is true', () => {
+ it('returns false', () => {
+ const state = {
+ isShowingEpicsSwimlanes: true,
+ };
+
+ expect(getters.isSwimlanesOn(state)).toBe(false);
+ });
+ });
+
+ describe('when isShowingEpicsSwimlanes is false', () => {
+ it('returns false', () => {
+ const state = {
+ isShowingEpicsSwimlanes: false,
+ };
+
+ expect(getters.isSwimlanesOn(state)).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('getIssueById', () => {
+ const state = { issues: { '1': 'issue' } };
+
+ it.each`
+ id | expected
+ ${'1'} | ${'issue'}
+ ${''} | ${{}}
+ `('returns $expected when $id is passed to state', ({ id, expected }) => {
+ expect(getters.getIssueById(state)(id)).toEqual(expected);
+ });
+ });
+
+ describe('getActiveIssue', () => {
+ it.each`
+ id | expected
+ ${'1'} | ${'issue'}
+ ${''} | ${{}}
+ `('returns $expected when $id is passed to state', ({ id, expected }) => {
+ const state = { issues: { '1': 'issue' }, activeId: id };
+
+ expect(getters.getActiveIssue(state)).toEqual(expected);
+ });
+ });
+
+ describe('getIssues', () => {
+ const boardsState = {
+ issuesByListId: mockIssuesByListId,
+ issues,
+ };
+ it('returns issues for a given listId', () => {
+ const getIssueById = issueId => [mockIssue, mockIssue2].find(({ id }) => id === issueId);
+
+ expect(getters.getIssues(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual(
+ mockIssues,
+ );
+ });
+ });
});
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index c1f7f3dda6e..a13a99a507e 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -1,6 +1,17 @@
import mutations from '~/boards/stores/mutations';
+import * as types from '~/boards/stores/mutation_types';
import defaultState from '~/boards/stores/state';
-import { mockIssue } from '../mock_data';
+import {
+ listObj,
+ listObjDuplicate,
+ mockListsWithModel,
+ mockLists,
+ rawIssue,
+ mockIssue,
+ mockIssue2,
+ mockIssueWithModel,
+ mockIssue2WithModel,
+} from '../mock_data';
const expectNotImplemented = action => {
it('is not implemented', () => {
@@ -26,21 +37,56 @@ describe('Board Store Mutations', () => {
fullPath: 'gitlab-org',
};
const boardType = 'group';
+ const disabled = false;
+ const showPromotion = false;
- mutations.SET_INITIAL_BOARD_DATA(state, { ...endpoints, boardType });
+ mutations[types.SET_INITIAL_BOARD_DATA](state, {
+ ...endpoints,
+ boardType,
+ disabled,
+ showPromotion,
+ });
expect(state.endpoints).toEqual(endpoints);
expect(state.boardType).toEqual(boardType);
+ expect(state.disabled).toEqual(disabled);
+ expect(state.showPromotion).toEqual(showPromotion);
+ });
+ });
+
+ describe('RECEIVE_BOARD_LISTS_SUCCESS', () => {
+ it('Should set boardLists to state', () => {
+ const lists = [listObj, listObjDuplicate];
+
+ mutations[types.RECEIVE_BOARD_LISTS_SUCCESS](state, lists);
+
+ expect(state.boardLists).toEqual(lists);
});
});
describe('SET_ACTIVE_ID', () => {
- it('updates activeListId to be the value that is passed', () => {
- const expectedId = 1;
+ const expected = { id: 1, sidebarType: '' };
- mutations.SET_ACTIVE_ID(state, expectedId);
+ beforeEach(() => {
+ mutations.SET_ACTIVE_ID(state, expected);
+ });
+
+ it('updates aciveListId to be the value that is passed', () => {
+ expect(state.activeId).toBe(expected.id);
+ });
- expect(state.activeId).toBe(expectedId);
+ it('updates sidebarType to be the value that is passed', () => {
+ expect(state.sidebarType).toBe(expected.sidebarType);
+ });
+ });
+
+ describe('SET_FILTERS', () => {
+ it('updates filterParams to be the value that is passed', () => {
+ const filterParams = { labelName: 'label' };
+
+ mutations.SET_FILTERS(state, filterParams);
+
+ expect(state.filterParams).toBe(filterParams);
});
});
@@ -56,16 +102,35 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_ADD_LIST_ERROR);
});
- describe('REQUEST_UPDATE_LIST', () => {
- expectNotImplemented(mutations.REQUEST_UPDATE_LIST);
- });
+ describe('MOVE_LIST', () => {
+ it('updates boardLists state with reordered lists', () => {
+ state = {
+ ...state,
+ boardLists: mockListsWithModel,
+ };
- describe('RECEIVE_UPDATE_LIST_SUCCESS', () => {
- expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_SUCCESS);
+ mutations.MOVE_LIST(state, {
+ movedList: mockListsWithModel[0],
+ listAtNewIndex: mockListsWithModel[1],
+ });
+
+ expect(state.boardLists).toEqual([mockListsWithModel[1], mockListsWithModel[0]]);
+ });
});
- describe('RECEIVE_UPDATE_LIST_ERROR', () => {
- expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_ERROR);
+ describe('UPDATE_LIST_FAILURE', () => {
+ it('updates boardLists state with previous order and sets error message', () => {
+ state = {
+ ...state,
+ boardLists: [mockListsWithModel[1], mockListsWithModel[0]],
+ error: undefined,
+ };
+
+ mutations.UPDATE_LIST_FAILURE(state, mockListsWithModel);
+
+ expect(state.boardLists).toEqual(mockListsWithModel);
+ expect(state.error).toEqual('An error occurred while updating the list. Please try again.');
+ });
});
describe('REQUEST_REMOVE_LIST', () => {
@@ -80,6 +145,33 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR);
});
+ describe('RECEIVE_ISSUES_FOR_LIST_SUCCESS', () => {
+ it('updates issuesByListId and issues on state', () => {
+ const listIssues = {
+ 'gid://gitlab/List/1': [mockIssue.id],
+ };
+ const issues = {
+ '1': mockIssue,
+ };
+
+ state = {
+ ...state,
+ isLoadingIssues: true,
+ issuesByListId: {},
+ issues: {},
+ boardLists: mockListsWithModel,
+ };
+
+ mutations.RECEIVE_ISSUES_FOR_LIST_SUCCESS(state, {
+ listIssues: { listData: listIssues, issues },
+ listId: 'gid://gitlab/List/1',
+ });
+
+ expect(state.issuesByListId).toEqual(listIssues);
+ expect(state.issues).toEqual(issues);
+ });
+ });
+
describe('REQUEST_ISSUES_FOR_ALL_LISTS', () => {
it('sets isLoadingIssues to true', () => {
expect(state.isLoadingIssues).toBe(false);
@@ -90,22 +182,45 @@ describe('Board Store Mutations', () => {
});
});
+ describe('RECEIVE_ISSUES_FOR_LIST_FAILURE', () => {
+ it('sets error message', () => {
+ state = {
+ ...state,
+ boardLists: mockListsWithModel,
+ error: undefined,
+ };
+
+ const listId = 'gid://gitlab/List/1';
+
+ mutations.RECEIVE_ISSUES_FOR_LIST_FAILURE(state, listId);
+
+ expect(state.error).toEqual(
+ 'An error occurred while fetching the board issues. Please reload the page.',
+ );
+ });
+ });
+
describe('RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS', () => {
it('sets isLoadingIssues to false and updates issuesByListId object', () => {
const listIssues = {
- '1': [mockIssue],
+ 'gid://gitlab/List/1': [mockIssue.id],
+ };
+ const issues = {
+ '1': mockIssue,
};
state = {
...state,
isLoadingIssues: true,
issuesByListId: {},
+ issues: {},
};
- mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS(state, listIssues);
+ mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS(state, { listData: listIssues, issues });
expect(state.isLoadingIssues).toBe(false);
expect(state.issuesByListId).toEqual(listIssues);
+ expect(state.issues).toEqual(issues);
});
});
@@ -113,6 +228,65 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.REQUEST_ADD_ISSUE);
});
+ describe('RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE', () => {
+ it('sets isLoadingIssues to false and sets error message', () => {
+ state = {
+ ...state,
+ isLoadingIssues: true,
+ error: undefined,
+ };
+
+ mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE(state);
+
+ expect(state.isLoadingIssues).toBe(false);
+ expect(state.error).toEqual(
+ 'An error occurred while fetching the board issues. Please reload the page.',
+ );
+ });
+ });
+
+ describe('UPDATE_ISSUE_BY_ID', () => {
+ const issueId = '1';
+ const prop = 'id';
+ const value = '2';
+ const issue = { [issueId]: { id: 1, title: 'Issue' } };
+
+ beforeEach(() => {
+ state = {
+ ...state,
+ isLoadingIssues: true,
+ error: undefined,
+ issues: {
+ ...issue,
+ },
+ };
+ });
+
+ describe('when the issue is in state', () => {
+ it('updates the property of the correct issue', () => {
+ mutations.UPDATE_ISSUE_BY_ID(state, {
+ issueId,
+ prop,
+ value,
+ });
+
+ expect(state.issues[issueId]).toEqual({ ...issue[issueId], id: '2' });
+ });
+ });
+
+ describe('when the issue is not in state', () => {
+ it('throws an error', () => {
+ expect(() => {
+ mutations.UPDATE_ISSUE_BY_ID(state, {
+ issueId: '3',
+ prop,
+ value,
+ });
+ }).toThrow(new Error('No issue found.'));
+ });
+ });
+ });
+
describe('RECEIVE_ADD_ISSUE_SUCCESS', () => {
expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_SUCCESS);
});
@@ -121,16 +295,86 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_ERROR);
});
- describe('REQUEST_MOVE_ISSUE', () => {
- expectNotImplemented(mutations.REQUEST_MOVE_ISSUE);
+ describe('MOVE_ISSUE', () => {
+ it('updates issuesByListId, moving issue between lists', () => {
+ const listIssues = {
+ 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
+ 'gid://gitlab/List/2': [],
+ };
+
+ const issues = {
+ '1': mockIssueWithModel,
+ '2': mockIssue2WithModel,
+ };
+
+ state = {
+ ...state,
+ issuesByListId: listIssues,
+ boardLists: mockListsWithModel,
+ issues,
+ };
+
+ mutations.MOVE_ISSUE(state, {
+ originalIssue: mockIssue2WithModel,
+ fromListId: 'gid://gitlab/List/1',
+ toListId: 'gid://gitlab/List/2',
+ });
+
+ const updatedListIssues = {
+ 'gid://gitlab/List/1': [mockIssue.id],
+ 'gid://gitlab/List/2': [mockIssue2.id],
+ };
+
+ expect(state.issuesByListId).toEqual(updatedListIssues);
+ });
});
- describe('RECEIVE_MOVE_ISSUE_SUCCESS', () => {
- expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_SUCCESS);
+ describe('MOVE_ISSUE_SUCCESS', () => {
+ it('updates issue in issues state', () => {
+ const issues = {
+ '436': { id: rawIssue.id },
+ };
+
+ state = {
+ ...state,
+ issues,
+ };
+
+ mutations.MOVE_ISSUE_SUCCESS(state, {
+ issue: rawIssue,
+ });
+
+ expect(state.issues).toEqual({ '436': { ...mockIssueWithModel, id: 436 } });
+ });
});
- describe('RECEIVE_MOVE_ISSUE_ERROR', () => {
- expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_ERROR);
+ describe('MOVE_ISSUE_FAILURE', () => {
+ it('updates issuesByListId, reverting moving issue between lists, and sets error message', () => {
+ const listIssues = {
+ 'gid://gitlab/List/1': [mockIssue.id],
+ 'gid://gitlab/List/2': [mockIssue2.id],
+ };
+
+ state = {
+ ...state,
+ issuesByListId: listIssues,
+ };
+
+ mutations.MOVE_ISSUE_FAILURE(state, {
+ originalIssue: mockIssue2,
+ fromListId: 'gid://gitlab/List/1',
+ toListId: 'gid://gitlab/List/2',
+ originalIndex: 1,
+ });
+
+ const updatedListIssues = {
+ 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
+ 'gid://gitlab/List/2': [],
+ };
+
+ expect(state.issuesByListId).toEqual(updatedListIssues);
+ expect(state.error).toEqual('An error occurred while moving the issue. Please try again.');
+ });
});
describe('REQUEST_UPDATE_ISSUE', () => {
@@ -145,6 +389,50 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR);
});
+ describe('ADD_ISSUE_TO_LIST', () => {
+ it('adds issue to issues state and issue id in list in issuesByListId', () => {
+ const listIssues = {
+ 'gid://gitlab/List/1': [mockIssue.id],
+ };
+ const issues = {
+ '1': mockIssue,
+ };
+
+ state = {
+ ...state,
+ issuesByListId: listIssues,
+ issues,
+ };
+
+ mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 });
+
+ expect(state.issuesByListId['gid://gitlab/List/1']).toContain(mockIssue2.id);
+ expect(state.issues[mockIssue2.id]).toEqual(mockIssue2);
+ });
+ });
+
+ describe('ADD_ISSUE_TO_LIST_FAILURE', () => {
+ it('removes issue id from list in issuesByListId', () => {
+ const listIssues = {
+ 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
+ };
+ const issues = {
+ '1': mockIssue,
+ '2': mockIssue2,
+ };
+
+ state = {
+ ...state,
+ issuesByListId: listIssues,
+ issues,
+ };
+
+ mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 });
+
+ expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
+ });
+ });
+
describe('SET_CURRENT_PAGE', () => {
expectNotImplemented(mutations.SET_CURRENT_PAGE);
});
diff --git a/spec/frontend/branches/ajax_loading_spinner_spec.js b/spec/frontend/branches/ajax_loading_spinner_spec.js
new file mode 100644
index 00000000000..a6404faa445
--- /dev/null
+++ b/spec/frontend/branches/ajax_loading_spinner_spec.js
@@ -0,0 +1,32 @@
+import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
+
+describe('Ajax Loading Spinner', () => {
+ let ajaxLoadingSpinnerElement;
+ let fauxEvent;
+ beforeEach(() => {
+ document.body.innerHTML = `
+ <div>
+ <a class="js-ajax-loading-spinner"
+ data-remote
+ href="http://goesnowhere.nothing/whereami">
+ <i class="fa fa-trash-o"></i>
+ </a></div>`;
+ AjaxLoadingSpinner.init();
+ ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner');
+ fauxEvent = { target: ajaxLoadingSpinnerElement };
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('`ajaxBeforeSend` event handler sets current icon to spinner and disables link', () => {
+ expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).toBeNull();
+ expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(false);
+
+ AjaxLoadingSpinner.ajaxBeforeSend(fauxEvent);
+
+ expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).not.toBeNull();
+ expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(true);
+ });
+});
diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
index 4e35243f484..ab32fb12058 100644
--- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
+++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
@@ -49,7 +49,7 @@ describe('Ci variable modal', () => {
});
it('does not render the autocomplete dropdown', () => {
- expect(wrapper.contains(GlFormCombobox)).toBe(false);
+ expect(wrapper.find(GlFormCombobox).exists()).toBe(false);
});
});
diff --git a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
new file mode 100644
index 00000000000..5577176bcc5
--- /dev/null
+++ b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
@@ -0,0 +1,8 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`NewCluster renders the cluster component correctly 1`] = `
+"<div>
+ <h4>Enter the details for your Kubernetes cluster</h4>
+ <p>Please enter access information for your Kubernetes cluster. If you need help, you can read our <b-link-stub href=\\"/some/help/path\\" target=\\"_blank\\" event=\\"click\\" routertag=\\"a\\" class=\\"gl-link\\">documentation</b-link-stub> on Kubernetes</p>
+</div>"
+`;
diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js
index b97d4dbf355..0a964426c95 100644
--- a/spec/frontend/clusters/components/application_row_spec.js
+++ b/spec/frontend/clusters/components/application_row_spec.js
@@ -48,7 +48,7 @@ describe('Application Row', () => {
describe('Install button', () => {
const button = () => wrapper.find('.js-cluster-application-install-button');
const checkButtonState = (label, loading, disabled) => {
- expect(button().props('label')).toEqual(label);
+ expect(button().text()).toEqual(label);
expect(button().props('loading')).toEqual(loading);
expect(button().props('disabled')).toEqual(disabled);
};
@@ -56,7 +56,7 @@ describe('Application Row', () => {
it('has indeterminate state on page load', () => {
mountComponent({ status: null });
- expect(button().props('label')).toBeUndefined();
+ expect(button().text()).toBe('');
});
it('has install button', () => {
@@ -225,7 +225,7 @@ describe('Application Row', () => {
mountComponent({ updateAvailable: true });
expect(button().exists()).toBe(true);
- expect(button().props('label')).toContain('Update');
+ expect(button().text()).toContain('Update');
});
it('has enabled "Retry update" when update process fails', () => {
@@ -235,14 +235,14 @@ describe('Application Row', () => {
});
expect(button().exists()).toBe(true);
- expect(button().props('label')).toContain('Retry update');
+ expect(button().text()).toContain('Retry update');
});
it('has disabled "Updating" when APPLICATION_STATUS.UPDATING', () => {
mountComponent({ status: APPLICATION_STATUS.UPDATING });
expect(button().exists()).toBe(true);
- expect(button().props('label')).toContain('Updating');
+ expect(button().text()).toContain('Updating');
});
it('clicking update button emits event', () => {
@@ -300,11 +300,11 @@ describe('Application Row', () => {
beforeEach(() => mountComponent({ updateAvailable: true }));
it('the modal is not rendered', () => {
- expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false);
+ expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(false);
});
it('the correct button is rendered', () => {
- expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true);
+ expect(wrapper.find("[data-qa-selector='update_button']").exists()).toBe(true);
});
});
@@ -318,11 +318,13 @@ describe('Application Row', () => {
});
it('displays a modal', () => {
- expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(true);
+ expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(true);
});
it('the correct button is rendered', () => {
- expect(wrapper.contains("[data-qa-selector='update_button_with_confirmation']")).toBe(true);
+ expect(wrapper.find("[data-qa-selector='update_button_with_confirmation']").exists()).toBe(
+ true,
+ );
});
it('triggers updateApplication event', () => {
@@ -344,8 +346,10 @@ describe('Application Row', () => {
version: '1.1.2',
});
- expect(wrapper.contains("[data-qa-selector='update_button_with_confirmation']")).toBe(true);
- expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(true);
+ expect(wrapper.find("[data-qa-selector='update_button_with_confirmation']").exists()).toBe(
+ true,
+ );
+ expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(true);
});
it('does not need confirmation is version is 3.0.0', () => {
@@ -355,8 +359,8 @@ describe('Application Row', () => {
version: '3.0.0',
});
- expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true);
- expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false);
+ expect(wrapper.find("[data-qa-selector='update_button']").exists()).toBe(true);
+ expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(false);
});
it('does not need confirmation if version is higher than 3.0.0', () => {
@@ -366,8 +370,8 @@ describe('Application Row', () => {
version: '5.2.1',
});
- expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true);
- expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false);
+ expect(wrapper.find("[data-qa-selector='update_button']").exists()).toBe(true);
+ expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
index 0bc4eb73bf9..c263679a45c 100644
--- a/spec/frontend/clusters/components/fluentd_output_settings_spec.js
+++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
@@ -168,7 +168,7 @@ describe('FluentdOutputSettings', () => {
});
it('displays a error message', () => {
- expect(wrapper.contains(GlAlert)).toBe(true);
+ expect(wrapper.find(GlAlert).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js
index a07258dcc69..11ebe1b5d61 100644
--- a/spec/frontend/clusters/components/knative_domain_editor_spec.js
+++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js
@@ -1,7 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui';
import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { APPLICATION_STATUS } from '~/clusters/constants';
const { UPDATING } = APPLICATION_STATUS;
@@ -79,7 +78,7 @@ describe('KnativeDomainEditor', () => {
});
it('triggers save event and pass current knative hostname', () => {
- wrapper.find(LoadingButton).vm.$emit('click');
+ wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('save').length).toEqual(1);
});
@@ -166,15 +165,15 @@ describe('KnativeDomainEditor', () => {
});
it('renders loading spinner in save button', () => {
- expect(wrapper.find(LoadingButton).props('loading')).toBe(true);
+ expect(wrapper.find(GlButton).props('loading')).toBe(true);
});
it('renders disabled save button', () => {
- expect(wrapper.find(LoadingButton).props('disabled')).toBe(true);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(true);
});
it('renders save button with "Saving" label', () => {
- expect(wrapper.find(LoadingButton).props('label')).toBe('Saving');
+ expect(wrapper.find(GlButton).text()).toBe('Saving');
});
});
});
diff --git a/spec/frontend/clusters/components/new_cluster_spec.js b/spec/frontend/clusters/components/new_cluster_spec.js
new file mode 100644
index 00000000000..bb4898f98ba
--- /dev/null
+++ b/spec/frontend/clusters/components/new_cluster_spec.js
@@ -0,0 +1,41 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import NewCluster from '~/clusters/components/new_cluster.vue';
+import createClusterStore from '~/clusters/stores/new_cluster';
+
+describe('NewCluster', () => {
+ let store;
+ let wrapper;
+
+ const createWrapper = () => {
+ store = createClusterStore({ clusterConnectHelpPath: '/some/help/path' });
+ wrapper = shallowMount(NewCluster, { store, stubs: { GlLink, GlSprintf } });
+ return wrapper.vm.$nextTick();
+ };
+
+ const findDescription = () => wrapper.find(GlSprintf);
+
+ const findLink = () => wrapper.find(GlLink);
+
+ beforeEach(() => {
+ return createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the cluster component correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('renders the correct information text', () => {
+ expect(findDescription().text()).toContain(
+ 'Please enter access information for your Kubernetes cluster.',
+ );
+ });
+
+ it('renders a valid help link set by the backend', () => {
+ expect(findLink().attributes('href')).toBe('/some/help/path');
+ });
+});
diff --git a/spec/frontend/clusters/components/uninstall_application_button_spec.js b/spec/frontend/clusters/components/uninstall_application_button_spec.js
index 9f9397d4d41..387e2188572 100644
--- a/spec/frontend/clusters/components/uninstall_application_button_spec.js
+++ b/spec/frontend/clusters/components/uninstall_application_button_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
import UninstallApplicationButton from '~/clusters/components/uninstall_application_button.vue';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { APPLICATION_STATUS } from '~/clusters/constants';
const { INSTALLED, UPDATING, UNINSTALLING } = APPLICATION_STATUS;
@@ -19,14 +19,21 @@ describe('UninstallApplicationButton', () => {
});
describe.each`
- status | loading | disabled | label
+ status | loading | disabled | text
${INSTALLED} | ${false} | ${false} | ${'Uninstall'}
${UPDATING} | ${false} | ${true} | ${'Uninstall'}
${UNINSTALLING} | ${true} | ${true} | ${'Uninstalling'}
- `('when app status is $status', ({ loading, disabled, status, label }) => {
- it(`renders a loading=${loading}, disabled=${disabled} button with label="${label}"`, () => {
+ `('when app status is $status', ({ loading, disabled, status, text }) => {
+ beforeAll(() => {
createComponent({ status });
- expect(wrapper.find(LoadingButton).props()).toMatchObject({ loading, disabled, label });
+ });
+
+ it(`renders a button with loading=${loading} and disabled=${disabled}`, () => {
+ expect(wrapper.find(GlButton).props()).toMatchObject({ loading, disabled });
+ });
+
+ it(`renders a button with text="${text}"`, () => {
+ expect(wrapper.find(GlButton).text()).toBe(text);
});
});
});
diff --git a/spec/frontend/clusters_list/components/ancestor_notice_spec.js b/spec/frontend/clusters_list/components/ancestor_notice_spec.js
index cff84180f26..436f1e97b04 100644
--- a/spec/frontend/clusters_list/components/ancestor_notice_spec.js
+++ b/spec/frontend/clusters_list/components/ancestor_notice_spec.js
@@ -28,7 +28,7 @@ describe('ClustersAncestorNotice', () => {
});
it('displays no notice', () => {
- expect(wrapper.isEmpty()).toBe(true);
+ expect(wrapper.html()).toBe('');
});
});
@@ -45,7 +45,7 @@ describe('ClustersAncestorNotice', () => {
});
it('displays link', () => {
- expect(wrapper.contains(GlLink)).toBe(true);
+ expect(wrapper.find(GlLink).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index c6a5f66a627..628c35ae839 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -1,6 +1,11 @@
import MockAdapter from 'axios-mock-adapter';
import { mount } from '@vue/test-utils';
-import { GlLoadingIcon, GlPagination, GlSkeletonLoading, GlTable } from '@gitlab/ui';
+import {
+ GlLoadingIcon,
+ GlPagination,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlTable,
+} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
import Clusters from '~/clusters_list/components/clusters.vue';
diff --git a/spec/frontend/collapsed_sidebar_todo_spec.js b/spec/frontend/collapsed_sidebar_todo_spec.js
index 0c74491aa74..b1a304fabcd 100644
--- a/spec/frontend/collapsed_sidebar_todo_spec.js
+++ b/spec/frontend/collapsed_sidebar_todo_spec.js
@@ -47,9 +47,9 @@ describe('Issuable right sidebar collapsed todo toggle', () => {
expect(
document
- .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg use')
- .getAttribute('xlink:href'),
- ).toContain('todo-add');
+ .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg')
+ .getAttribute('data-testid'),
+ ).toBe('todo-add-icon');
expect(
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
@@ -72,9 +72,9 @@ describe('Issuable right sidebar collapsed todo toggle', () => {
expect(
document
- .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg.todo-undone use')
- .getAttribute('xlink:href'),
- ).toContain('todo-done');
+ .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg.todo-undone')
+ .getAttribute('data-testid'),
+ ).toBe('todo-done-icon');
done();
});
diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
index f7b68d96129..271c6356f7e 100644
--- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
+++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
@@ -33,9 +33,9 @@ exports[`Confidential merge request project form group component renders empty s
Read more
</span>
- <i
- aria-hidden="true"
- class="fa fa-question-circle"
+ <gl-icon-stub
+ name="question-o"
+ size="16"
/>
</gl-link-stub>
</p>
@@ -76,9 +76,9 @@ exports[`Confidential merge request project form group component renders fork dr
Read more
</span>
- <i
- aria-hidden="true"
- class="fa fa-question-circle"
+ <gl-icon-stub
+ name="question-o"
+ size="16"
/>
</gl-link-stub>
</p>
diff --git a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js
index 14f2a527dfb..17abf409717 100644
--- a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js
+++ b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js
@@ -92,7 +92,7 @@ describe('ClusterFormDropdown', () => {
});
it('displays a checked GlIcon next to the item', () => {
- expect(wrapper.find(GlIcon).is('.invisible')).toBe(false);
+ expect(wrapper.find(GlIcon).classes()).not.toContain('invisible');
expect(wrapper.find(GlIcon).props('name')).toBe('mobile-issue-close');
});
});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
index 34d9ee733c4..d7dd7072f67 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
@@ -147,6 +147,7 @@ describe('EksClusterConfigurationForm', () => {
initialState: {
clusterName: 'cluster name',
environmentScope: '*',
+ kubernetesVersion: '1.16',
selectedRegion: 'region',
selectedRole: 'role',
selectedKeyPair: 'key pair',
@@ -400,43 +401,33 @@ describe('EksClusterConfigurationForm', () => {
});
it('dispatches setRegion action', () => {
- expect(actions.setRegion).toHaveBeenCalledWith(expect.anything(), { region }, undefined);
+ expect(actions.setRegion).toHaveBeenCalledWith(expect.anything(), { region });
});
it('fetches available vpcs', () => {
- expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }, undefined);
+ expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region });
});
it('fetches available key pairs', () => {
- expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(
- expect.anything(),
- { region },
- undefined,
- );
+ expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region });
});
it('cleans selected vpc', () => {
- expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }, undefined);
+ expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null });
});
it('cleans selected key pair', () => {
- expect(actions.setKeyPair).toHaveBeenCalledWith(
- expect.anything(),
- { keyPair: null },
- undefined,
- );
+ expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair: null });
});
it('cleans selected subnet', () => {
- expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }, undefined);
+ expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] });
});
it('cleans selected security group', () => {
- expect(actions.setSecurityGroup).toHaveBeenCalledWith(
- expect.anything(),
- { securityGroup: null },
- undefined,
- );
+ expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), {
+ securityGroup: null,
+ });
});
});
@@ -445,11 +436,7 @@ describe('EksClusterConfigurationForm', () => {
findClusterNameInput().vm.$emit('input', clusterName);
- expect(actions.setClusterName).toHaveBeenCalledWith(
- expect.anything(),
- { clusterName },
- undefined,
- );
+ expect(actions.setClusterName).toHaveBeenCalledWith(expect.anything(), { clusterName });
});
it('dispatches setEnvironmentScope when environment scope input changes', () => {
@@ -457,11 +444,9 @@ describe('EksClusterConfigurationForm', () => {
findEnvironmentScopeInput().vm.$emit('input', environmentScope);
- expect(actions.setEnvironmentScope).toHaveBeenCalledWith(
- expect.anything(),
- { environmentScope },
- undefined,
- );
+ expect(actions.setEnvironmentScope).toHaveBeenCalledWith(expect.anything(), {
+ environmentScope,
+ });
});
it('dispatches setKubernetesVersion when kubernetes version dropdown changes', () => {
@@ -469,11 +454,9 @@ describe('EksClusterConfigurationForm', () => {
findKubernetesVersionDropdown().vm.$emit('input', kubernetesVersion);
- expect(actions.setKubernetesVersion).toHaveBeenCalledWith(
- expect.anything(),
- { kubernetesVersion },
- undefined,
- );
+ expect(actions.setKubernetesVersion).toHaveBeenCalledWith(expect.anything(), {
+ kubernetesVersion,
+ });
});
it('dispatches setGitlabManagedCluster when gitlab managed cluster input changes', () => {
@@ -481,11 +464,9 @@ describe('EksClusterConfigurationForm', () => {
findGitlabManagedClusterCheckbox().vm.$emit('input', gitlabManagedCluster);
- expect(actions.setGitlabManagedCluster).toHaveBeenCalledWith(
- expect.anything(),
- { gitlabManagedCluster },
- undefined,
- );
+ expect(actions.setGitlabManagedCluster).toHaveBeenCalledWith(expect.anything(), {
+ gitlabManagedCluster,
+ });
});
describe('when vpc is selected', () => {
@@ -498,35 +479,28 @@ describe('EksClusterConfigurationForm', () => {
});
it('dispatches setVpc action', () => {
- expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined);
+ expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc });
});
it('cleans selected subnet', () => {
- expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }, undefined);
+ expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] });
});
it('cleans selected security group', () => {
- expect(actions.setSecurityGroup).toHaveBeenCalledWith(
- expect.anything(),
- { securityGroup: null },
- undefined,
- );
+ expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), {
+ securityGroup: null,
+ });
});
it('dispatches fetchSubnets action', () => {
- expect(subnetsActions.fetchItems).toHaveBeenCalledWith(
- expect.anything(),
- { vpc, region },
- undefined,
- );
+ expect(subnetsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { vpc, region });
});
it('dispatches fetchSecurityGroups action', () => {
- expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith(
- expect.anything(),
- { vpc, region },
- undefined,
- );
+ expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), {
+ vpc,
+ region,
+ });
});
});
@@ -538,7 +512,7 @@ describe('EksClusterConfigurationForm', () => {
});
it('dispatches setSubnet action', () => {
- expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet }, undefined);
+ expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet });
});
});
@@ -550,7 +524,7 @@ describe('EksClusterConfigurationForm', () => {
});
it('dispatches setRole action', () => {
- expect(actions.setRole).toHaveBeenCalledWith(expect.anything(), { role }, undefined);
+ expect(actions.setRole).toHaveBeenCalledWith(expect.anything(), { role });
});
});
@@ -562,7 +536,7 @@ describe('EksClusterConfigurationForm', () => {
});
it('dispatches setKeyPair action', () => {
- expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair }, undefined);
+ expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair });
});
});
@@ -574,11 +548,7 @@ describe('EksClusterConfigurationForm', () => {
});
it('dispatches setSecurityGroup action', () => {
- expect(actions.setSecurityGroup).toHaveBeenCalledWith(
- expect.anything(),
- { securityGroup },
- undefined,
- );
+ expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { securityGroup });
});
});
@@ -590,11 +560,7 @@ describe('EksClusterConfigurationForm', () => {
});
it('dispatches setInstanceType action', () => {
- expect(actions.setInstanceType).toHaveBeenCalledWith(
- expect.anything(),
- { instanceType },
- undefined,
- );
+ expect(actions.setInstanceType).toHaveBeenCalledWith(expect.anything(), { instanceType });
});
});
@@ -603,7 +569,7 @@ describe('EksClusterConfigurationForm', () => {
findNodeCountInput().vm.$emit('input', nodeCount);
- expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount }, undefined);
+ expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount });
});
describe('when all cluster configuration fields are set', () => {
diff --git a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js
index c58638f5c80..d2d6db31d1b 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js
@@ -1,9 +1,7 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-
+import { GlButton } from '@gitlab/ui';
import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
-
import eksClusterState from '~/create_cluster/eks_cluster/store/state';
const localVue = createLocalVue();
@@ -46,7 +44,7 @@ describe('ServiceCredentialsForm', () => {
const findExternalIdInput = () => vm.find('#eks-external-id');
const findCopyExternalIdButton = () => vm.find('.js-copy-external-id-button');
const findInvalidCredentials = () => vm.find('.js-invalid-credentials');
- const findSubmitButton = () => vm.find(LoadingButton);
+ const findSubmitButton = () => vm.find(GlButton);
it('displays provided account id', () => {
expect(findAccountIdInput().attributes('value')).toBe(accountId);
@@ -102,7 +100,7 @@ describe('ServiceCredentialsForm', () => {
});
it('displays Authenticating label on submit button', () => {
- expect(findSubmitButton().props('label')).toBe('Authenticating');
+ expect(findSubmitButton().text()).toBe('Authenticating');
});
});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
index 882a4a002bd..ed753888790 100644
--- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
@@ -47,7 +47,7 @@ describe('EKS Cluster Store Actions', () => {
beforeEach(() => {
clusterName = 'my cluster';
environmentScope = 'production';
- kubernetesVersion = '11.1';
+ kubernetesVersion = '1.16';
region = 'regions-1';
vpc = 'vpc-1';
subnet = 'subnet-1';
@@ -180,6 +180,7 @@ describe('EKS Cluster Store Actions', () => {
environment_scope: environmentScope,
managed: gitlabManagedCluster,
provider_aws_attributes: {
+ kubernetes_version: kubernetesVersion,
region,
vpc_id: vpc,
subnet_ids: subnet,
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js
index 57ef74f0119..c09eaa63d4d 100644
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js
+++ b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js
@@ -124,11 +124,7 @@ describe('GkeMachineTypeDropdown', () => {
wrapper.find('.dropdown-content button').trigger('click');
return wrapper.vm.$nextTick().then(() => {
- expect(setMachineType).toHaveBeenCalledWith(
- expect.anything(),
- selectedMachineTypeMock,
- undefined,
- );
+ expect(setMachineType).toHaveBeenCalledWith(expect.anything(), selectedMachineTypeMock);
});
});
});
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js
index 1df583af711..ce24d186511 100644
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js
+++ b/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js
@@ -121,23 +121,19 @@ describe('GkeNetworkDropdown', () => {
});
it('cleans selected subnetwork', () => {
- expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), '', undefined);
+ expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), '');
});
it('dispatches the setNetwork action', () => {
- expect(setNetwork).toHaveBeenCalledWith(expect.anything(), selectedNetwork, undefined);
+ expect(setNetwork).toHaveBeenCalledWith(expect.anything(), selectedNetwork);
});
it('fetches subnetworks for the selected project, region, and network', () => {
- expect(fetchSubnetworks).toHaveBeenCalledWith(
- expect.anything(),
- {
- project: projectId,
- region,
- network: selectedNetwork.selfLink,
- },
- undefined,
- );
+ expect(fetchSubnetworks).toHaveBeenCalledWith(expect.anything(), {
+ project: projectId,
+ region,
+ network: selectedNetwork.selfLink,
+ });
});
});
});
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js
index 0d429778a44..eb58108bf3c 100644
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js
+++ b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js
@@ -130,7 +130,6 @@ describe('GkeProjectIdDropdown', () => {
expect(setProject).toHaveBeenCalledWith(
expect.anything(),
gapiProjectsResponseMock.projects[0],
- undefined,
);
});
});
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js
index a1dc3960fe9..35e43d5b033 100644
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js
+++ b/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js
@@ -107,7 +107,7 @@ describe('GkeSubnetworkDropdown', () => {
wrapper.find(ClusterFormDropdown).vm.$emit('input', selectedSubnetwork);
- expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), selectedSubnetwork, undefined);
+ expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), selectedSubnetwork);
});
});
});
diff --git a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js
index 644cd0b5f27..99cb864ce34 100644
--- a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js
+++ b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js
@@ -1,6 +1,6 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlDeprecatedDropdownItem, GlNewDropdown } from '@gitlab/ui';
+import { GlDeprecatedDropdownItem, GlDropdown } from '@gitlab/ui';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue';
import createStore from '~/deploy_freeze/store';
@@ -92,7 +92,7 @@ describe('Deploy freeze timezone dropdown', () => {
});
it('renders selected time zone as dropdown label', () => {
- expect(wrapper.find(GlNewDropdown).vm.text).toBe('Alaska');
+ expect(wrapper.find(GlDropdown).vm.text).toBe('Alaska');
});
});
});
diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js
index 7d942d969bb..0b1cbd28274 100644
--- a/spec/frontend/deploy_keys/components/key_spec.js
+++ b/spec/frontend/deploy_keys/components/key_spec.js
@@ -55,20 +55,20 @@ describe('Deploy keys key', () => {
it('shows pencil button for editing', () => {
createComponent({ deployKey });
- expect(wrapper.find('.btn .ic-pencil')).toExist();
+ expect(wrapper.find('.btn [data-testid="pencil-icon"]')).toExist();
});
it('shows disable button when the project is not deletable', () => {
createComponent({ deployKey });
- expect(wrapper.find('.btn .ic-cancel')).toExist();
+ expect(wrapper.find('.btn [data-testid="cancel-icon"]')).toExist();
});
it('shows remove button when the project is deletable', () => {
createComponent({
deployKey: { ...deployKey, destroyed_when_orphaned: true, almost_orphaned: true },
});
- expect(wrapper.find('.btn .ic-remove')).toExist();
+ expect(wrapper.find('.btn [data-testid="remove-icon"]')).toExist();
});
});
@@ -147,7 +147,7 @@ describe('Deploy keys key', () => {
it('shows pencil button for editing', () => {
createComponent({ deployKey });
- expect(wrapper.find('.btn .ic-pencil')).toExist();
+ expect(wrapper.find('.btn [data-testid="pencil-icon"]')).toExist();
});
it('shows disable button when key is enabled', () => {
@@ -155,7 +155,7 @@ describe('Deploy keys key', () => {
createComponent({ deployKey });
- expect(wrapper.find('.btn .ic-cancel')).toExist();
+ expect(wrapper.find('.btn [data-testid="cancel-icon"]')).toExist();
});
});
});
diff --git a/spec/frontend/gl_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js
index 8bfe7f56e37..e6323859899 100644
--- a/spec/frontend/gl_dropdown_spec.js
+++ b/spec/frontend/deprecated_jquery_dropdown_spec.js
@@ -1,7 +1,7 @@
/* eslint-disable no-param-reassign */
import $ from 'jquery';
-import '~/gl_dropdown';
+import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import '~/lib/utils/common_utils';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -9,8 +9,8 @@ jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrl'),
}));
-describe('glDropdown', () => {
- preloadFixtures('static/gl_dropdown.html');
+describe('deprecatedJQueryDropdown', () => {
+ preloadFixtures('static/deprecated_jquery_dropdown.html');
const NON_SELECTABLE_CLASSES =
'.divider, .separator, .dropdown-header, .dropdown-menu-empty-item';
@@ -60,14 +60,12 @@ describe('glDropdown', () => {
id: project => project.id,
...extraOpts,
};
- test.dropdownButtonElement = $(
- '#js-project-dropdown',
- test.dropdownContainerElement,
- ).glDropdown(options);
+ test.dropdownButtonElement = $('#js-project-dropdown', test.dropdownContainerElement);
+ initDeprecatedJQueryDropdown(test.dropdownButtonElement, options);
}
beforeEach(() => {
- loadFixtures('static/gl_dropdown.html');
+ loadFixtures('static/deprecated_jquery_dropdown.html');
test.dropdownContainerElement = $('.dropdown.inline');
test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement);
test.projectsData = getJSONFixture('static/projects.json');
@@ -248,9 +246,9 @@ describe('glDropdown', () => {
function dropdownWithOptions(options) {
const $dropdownDiv = $('<div />');
- $dropdownDiv.glDropdown(options);
+ initDeprecatedJQueryDropdown($dropdownDiv, options);
- return $dropdownDiv.data('glDropdown');
+ return $dropdownDiv.data('deprecatedJQueryDropdown');
}
function basicDropdown() {
@@ -315,6 +313,42 @@ describe('glDropdown', () => {
expect(li.childNodes.length).toEqual(1);
expect(li.textContent).toEqual(text);
});
+
+ describe('with a trackSuggestionsClickedLabel', () => {
+ it('it includes data-track attributes', () => {
+ const dropdown = dropdownWithOptions({
+ trackSuggestionClickedLabel: 'some_value_for_label',
+ });
+ const item = {
+ id: 'some-element-id',
+ text: 'the link text',
+ url: 'http://example.com',
+ category: 'Suggestion category',
+ };
+ const li = dropdown.renderItem(item, null, 3);
+ const link = li.querySelector('a');
+
+ expect(link).toHaveAttr('data-track-event', 'click_text');
+ expect(link).toHaveAttr('data-track-label', 'some_value_for_label');
+ expect(link).toHaveAttr('data-track-value', '3');
+ expect(link).toHaveAttr('data-track-property', 'suggestion-category');
+ });
+
+ it('it defaults property to no_category when category not provided', () => {
+ const dropdown = dropdownWithOptions({
+ trackSuggestionClickedLabel: 'some_value_for_label',
+ });
+ const item = {
+ id: 'some-element-id',
+ text: 'the link text',
+ url: 'http://example.com',
+ };
+ const li = dropdown.renderItem(item);
+ const link = li.querySelector('a');
+
+ expect(link).toHaveAttr('data-track-property', 'no-category');
+ });
+ });
});
it('should keep selected item after selecting a second time', () => {
diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js
index cd4ef1f0ccd..961f5bdd2ae 100644
--- a/spec/frontend/design_management/components/delete_button_spec.js
+++ b/spec/frontend/design_management/components/delete_button_spec.js
@@ -8,7 +8,7 @@ describe('Batch delete button component', () => {
const findButton = () => wrapper.find(GlButton);
const findModal = () => wrapper.find(GlModal);
- function createComponent(isDeleting = false) {
+ function createComponent({ isDeleting = false } = {}, { slots = {} } = {}) {
wrapper = shallowMount(BatchDeleteButton, {
propsData: {
isDeleting,
@@ -16,6 +16,7 @@ describe('Batch delete button component', () => {
directives: {
GlModalDirective,
},
+ slots,
});
}
@@ -31,7 +32,7 @@ describe('Batch delete button component', () => {
});
it('renders disabled button when design is deleting', () => {
- createComponent(true);
+ createComponent({ isDeleting: true });
expect(findButton().attributes('disabled')).toBeTruthy();
});
@@ -48,4 +49,18 @@ describe('Batch delete button component', () => {
expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy();
});
});
+
+ it('renders slot content', () => {
+ const testText = 'Archive selected';
+ createComponent(
+ {},
+ {
+ slots: {
+ default: testText,
+ },
+ },
+ );
+
+ expect(findButton().text()).toBe(testText);
+ });
});
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 b55bacb6fc5..084a7e5d712 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
@@ -17,15 +17,15 @@ exports[`Design note component should match the snapshot 1`] = `
/>
<div
- class="d-flex justify-content-between"
+ class="gl-display-flex gl-justify-content-space-between"
>
<div>
- <a
+ <gl-link-stub
class="js-user-link"
data-user-id="author-id"
>
<span
- class="note-header-author-name bold"
+ class="note-header-author-name gl-font-weight-bold"
>
</span>
@@ -37,7 +37,7 @@ exports[`Design note component should match the snapshot 1`] = `
>
@
</span>
- </a>
+ </gl-link-stub>
<span
class="note-headline-light note-headline-meta"
@@ -46,12 +46,21 @@ exports[`Design note component should match the snapshot 1`] = `
class="system-note-message"
/>
- <!---->
+ <gl-link-stub
+ class="note-timestamp system-note-separator gl-display-block gl-mb-2"
+ href="#note_123"
+ >
+ <time-ago-tooltip-stub
+ cssclass=""
+ time="2019-07-26T15:02:20Z"
+ tooltipplacement="bottom"
+ />
+ </gl-link-stub>
</span>
</div>
<div
- class="gl-display-flex"
+ class="gl-display-flex gl-align-items-baseline"
>
<!---->
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
index e01c79e3520..f8c68ca4c83 100644
--- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
@@ -1,15 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = `
-"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
+"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled gl-button\\">
<!---->
- Comment
-</button>"
+ <!----> <span class=\\"gl-button-text\\">
+ Comment
+ </span></button>"
`;
exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = `
-"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
+"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled gl-button\\">
<!---->
- Save comment
-</button>"
+ <!----> <span class=\\"gl-button-text\\">
+ Save comment
+ </span></button>"
`;
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 176c10ea584..9fbd9b2c2a3 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
@@ -8,8 +8,9 @@ import createNoteMutation from '~/design_management/graphql/mutations/create_not
import toggleResolveDiscussionMutation from '~/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue';
+import mockDiscussion from '../../mock_data/discussion';
-const discussion = {
+const defaultMockDiscussion = {
id: '0',
resolved: false,
resolvable: true,
@@ -31,7 +32,6 @@ describe('Design discussions component', () => {
const mutationVariables = {
mutation: createNoteMutation,
- update: expect.anything(),
variables: {
input: {
noteableId: 'noteable-id',
@@ -40,7 +40,7 @@ describe('Design discussions component', () => {
},
},
};
- const mutate = jest.fn(() => Promise.resolve());
+ const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } });
const $apollo = {
mutate,
};
@@ -49,7 +49,7 @@ describe('Design discussions component', () => {
wrapper = mount(DesignDiscussion, {
propsData: {
resolvedDiscussionsExpanded: true,
- discussion,
+ discussion: defaultMockDiscussion,
noteableId: 'noteable-id',
designId: 'design-id',
discussionIndex: 1,
@@ -82,7 +82,7 @@ describe('Design discussions component', () => {
beforeEach(() => {
createComponent({
discussion: {
- ...discussion,
+ ...defaultMockDiscussion,
resolvable: false,
},
});
@@ -93,7 +93,7 @@ describe('Design discussions component', () => {
});
it('does not render a checkbox in reply form', () => {
- findReplyPlaceholder().vm.$emit('onMouseDown');
+ findReplyPlaceholder().vm.$emit('onClick');
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().exists()).toBe(false);
@@ -125,7 +125,7 @@ describe('Design discussions component', () => {
it('renders a checkbox with Resolve thread text in reply form', () => {
findReplyPlaceholder().vm.$emit('onClick');
- wrapper.setProps({ discussionWithOpenForm: discussion.id });
+ wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id });
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().text()).toBe('Resolve thread');
@@ -141,7 +141,7 @@ describe('Design discussions component', () => {
beforeEach(() => {
createComponent({
discussion: {
- ...discussion,
+ ...defaultMockDiscussion,
resolved: true,
resolvedBy: notes[0].author,
resolvedAt: '2020-05-08T07:10:45Z',
@@ -206,7 +206,7 @@ describe('Design discussions component', () => {
it('renders a checkbox with Unresolve thread text in reply form', () => {
findReplyPlaceholder().vm.$emit('onClick');
- wrapper.setProps({ discussionWithOpenForm: discussion.id });
+ wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id });
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().text()).toBe('Unresolve thread');
@@ -218,7 +218,7 @@ describe('Design discussions component', () => {
it('hides reply placeholder and opens form on placeholder click', () => {
createComponent();
findReplyPlaceholder().vm.$emit('onClick');
- wrapper.setProps({ discussionWithOpenForm: discussion.id });
+ wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id });
return wrapper.vm.$nextTick().then(() => {
expect(findReplyPlaceholder().exists()).toBe(false);
@@ -226,34 +226,31 @@ describe('Design discussions component', () => {
});
});
- it('calls mutation on submitting form and closes the form', () => {
+ it('calls mutation on submitting form and closes the form', async () => {
createComponent(
- { discussionWithOpenForm: discussion.id },
+ { discussionWithOpenForm: defaultMockDiscussion.id },
{ discussionComment: 'test', isFormRendered: true },
);
- findReplyForm().vm.$emit('submitForm');
+ findReplyForm().vm.$emit('submit-form');
expect(mutate).toHaveBeenCalledWith(mutationVariables);
- return mutate()
- .then(() => {
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(findReplyForm().exists()).toBe(false);
- });
+ await mutate();
+ await wrapper.vm.$nextTick();
+
+ expect(findReplyForm().exists()).toBe(false);
});
it('clears the discussion comment on closing comment form', () => {
createComponent(
- { discussionWithOpenForm: discussion.id },
+ { discussionWithOpenForm: defaultMockDiscussion.id },
{ discussionComment: 'test', isFormRendered: true },
);
return wrapper.vm
.$nextTick()
.then(() => {
- findReplyForm().vm.$emit('cancelForm');
+ findReplyForm().vm.$emit('cancel-form');
expect(wrapper.vm.discussionComment).toBe('');
return wrapper.vm.$nextTick();
@@ -263,19 +260,26 @@ describe('Design discussions component', () => {
});
});
- it('applies correct class to design notes when discussion is highlighted', () => {
- createComponent(
- {},
- {
- activeDiscussion: {
- id: notes[0].id,
- source: 'pin',
- },
- },
- );
+ describe('when any note from a discussion is active', () => {
+ it.each([notes[0], notes[0].discussion.notes.nodes[1]])(
+ 'applies correct class to all notes in the active discussion',
+ note => {
+ createComponent(
+ { discussion: mockDiscussion },
+ {
+ activeDiscussion: {
+ id: note.id,
+ source: 'pin',
+ },
+ },
+ );
- expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe(
- true,
+ expect(
+ wrapper
+ .findAll(DesignNote)
+ .wrappers.every(designNote => designNote.classes('gl-bg-blue-50')),
+ ).toBe(true);
+ },
);
});
@@ -285,7 +289,7 @@ describe('Design discussions component', () => {
expect(mutate).toHaveBeenCalledWith({
mutation: toggleResolveDiscussionMutation,
variables: {
- id: discussion.id,
+ id: defaultMockDiscussion.id,
resolve: true,
},
});
@@ -296,7 +300,7 @@ describe('Design discussions component', () => {
it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => {
createComponent(
- { discussionWithOpenForm: discussion.id },
+ { discussionWithOpenForm: defaultMockDiscussion.id },
{ discussionComment: 'test', isFormRendered: true },
);
findResolveButton().trigger('click');
@@ -306,7 +310,7 @@ describe('Design discussions component', () => {
expect(mutate).toHaveBeenCalledWith({
mutation: toggleResolveDiscussionMutation,
variables: {
- id: discussion.id,
+ id: defaultMockDiscussion.id,
resolve: true,
},
});
@@ -317,6 +321,6 @@ describe('Design discussions component', () => {
createComponent();
findReplyPlaceholder().vm.$emit('onClick');
- expect(wrapper.emitted('openForm')).toBeTruthy();
+ expect(wrapper.emitted('open-form')).toBeTruthy();
});
});
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 8b32d3022ee..043091e3dc2 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
@@ -15,6 +15,7 @@ const note = {
userPermissions: {
adminNote: false,
},
+ createdAt: '2019-07-26T15:02:20Z',
};
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
@@ -79,21 +80,10 @@ describe('Design note component', () => {
it('should render a time ago tooltip if note has createdAt property', () => {
createComponent({
- note: {
- ...note,
- createdAt: '2019-07-26T15:02:20Z',
- },
- });
-
- expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
- });
-
- it('should trigger a scrollIntoView method', () => {
- createComponent({
note,
});
- expect(scrollIntoViewMock).toHaveBeenCalled();
+ expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
});
it('should not render edit icon when user does not have a permission', () => {
@@ -143,8 +133,8 @@ describe('Design note component', () => {
expect(findReplyForm().exists()).toBe(true);
});
- it('hides the form on hideForm event', () => {
- findReplyForm().vm.$emit('cancelForm');
+ it('hides the form on cancel-form event', () => {
+ findReplyForm().vm.$emit('cancel-form');
return wrapper.vm.$nextTick().then(() => {
expect(findReplyForm().exists()).toBe(false);
@@ -152,8 +142,8 @@ describe('Design note component', () => {
});
});
- it('calls a mutation on submitForm event and hides a form', () => {
- findReplyForm().vm.$emit('submitForm');
+ it('calls a mutation on submit-form event and hides a form', () => {
+ findReplyForm().vm.$emit('submit-form');
expect(mutate).toHaveBeenCalled();
return mutate()
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 16b34f150b8..1a80fc4e761 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
@@ -70,7 +70,7 @@ describe('Design reply form component', () => {
});
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeFalsy();
+ expect(wrapper.emitted('submit-form')).toBeFalsy();
});
});
@@ -80,20 +80,20 @@ describe('Design reply form component', () => {
});
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeFalsy();
+ expect(wrapper.emitted('submit-form')).toBeFalsy();
});
});
it('emits cancelForm event on pressing escape button on textarea', () => {
findTextarea().trigger('keyup.esc');
- expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ expect(wrapper.emitted('cancel-form')).toBeTruthy();
});
it('emits cancelForm event on clicking Cancel button', () => {
findCancelButton().vm.$emit('click');
- expect(wrapper.emitted('cancelForm')).toHaveLength(1);
+ expect(wrapper.emitted('cancel-form')).toHaveLength(1);
});
});
@@ -112,7 +112,7 @@ describe('Design reply form component', () => {
findSubmitButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeTruthy();
+ expect(wrapper.emitted('submit-form')).toBeTruthy();
});
});
@@ -122,7 +122,7 @@ describe('Design reply form component', () => {
});
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeTruthy();
+ expect(wrapper.emitted('submit-form')).toBeTruthy();
});
});
@@ -132,7 +132,7 @@ describe('Design reply form component', () => {
});
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeTruthy();
+ expect(wrapper.emitted('submit-form')).toBeTruthy();
});
});
@@ -147,7 +147,7 @@ describe('Design reply form component', () => {
it('emits cancelForm event on Escape key if text was not changed', () => {
findTextarea().trigger('keyup.esc');
- expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ expect(wrapper.emitted('cancel-form')).toBeTruthy();
});
it('opens confirmation modal on Escape key when text has changed', () => {
@@ -162,7 +162,7 @@ describe('Design reply form component', () => {
it('emits cancelForm event on Cancel button click if text was not changed', () => {
findCancelButton().trigger('click');
- expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ expect(wrapper.emitted('cancel-form')).toBeTruthy();
});
it('opens confirmation modal on Cancel button click when text has changed', () => {
@@ -178,7 +178,7 @@ describe('Design reply form component', () => {
findTextarea().trigger('keyup.esc');
findModal().vm.$emit('ok');
- expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ expect(wrapper.emitted('cancel-form')).toBeTruthy();
});
});
});
diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js
index f243323b162..673a09320e5 100644
--- a/spec/frontend/design_management/components/design_overlay_spec.js
+++ b/spec/frontend/design_management/components/design_overlay_spec.js
@@ -11,11 +11,11 @@ describe('Design overlay component', () => {
const mockDimensions = { width: 100, height: 100 };
- const findOverlay = () => wrapper.find('.image-diff-overlay');
const findAllNotes = () => wrapper.findAll('.js-image-badge');
const findCommentBadge = () => wrapper.find('.comment-indicator');
- const findFirstBadge = () => findAllNotes().at(0);
- const findSecondBadge = () => findAllNotes().at(1);
+ const findBadgeAtIndex = noteIndex => findAllNotes().at(noteIndex);
+ const findFirstBadge = () => findBadgeAtIndex(0);
+ const findSecondBadge = () => findBadgeAtIndex(1);
const clickAndDragBadge = (elem, fromPoint, toPoint) => {
elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y });
@@ -56,9 +56,7 @@ describe('Design overlay component', () => {
it('should have correct inline style', () => {
createComponent();
- expect(wrapper.find('.image-diff-overlay').attributes().style).toBe(
- 'width: 100px; height: 100px; top: 0px; left: 0px;',
- );
+ expect(wrapper.attributes().style).toBe('width: 100px; height: 100px; top: 0px; left: 0px;');
});
it('should emit `openCommentForm` when clicking on overlay', () => {
@@ -69,7 +67,7 @@ describe('Design overlay component', () => {
};
wrapper
- .find('.image-diff-overlay-add-comment')
+ .find('[data-qa-selector="design_image_button"]')
.trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('openCommentForm')).toEqual([
@@ -107,16 +105,43 @@ describe('Design overlay component', () => {
expect(findSecondBadge().classes()).toContain('resolved');
});
- it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => {
- wrapper.setData({
- activeDiscussion: {
- id: notes[0].id,
- source: 'discussion',
- },
+ describe('when no discussion is active', () => {
+ it('should not apply inactive class to any pins', () => {
+ expect(
+ findAllNotes(0).wrappers.every(designNote => designNote.classes('gl-bg-blue-50')),
+ ).toBe(false);
});
+ });
+
+ describe('when a discussion is active', () => {
+ it.each([notes[0].discussion.notes.nodes[1], notes[0].discussion.notes.nodes[0]])(
+ 'should not apply inactive class to the pin for the active discussion',
+ note => {
+ wrapper.setData({
+ activeDiscussion: {
+ id: note.id,
+ source: 'discussion',
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findBadgeAtIndex(0).classes()).not.toContain('inactive');
+ });
+ },
+ );
+
+ it('should apply inactive class to all pins besides the active one', () => {
+ wrapper.setData({
+ activeDiscussion: {
+ id: notes[0].id,
+ source: 'discussion',
+ },
+ });
- return wrapper.vm.$nextTick().then(() => {
- expect(findSecondBadge().classes()).toContain('inactive');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findSecondBadge().classes()).toContain('inactive');
+ expect(findFirstBadge().classes()).not.toContain('inactive');
+ });
});
});
});
@@ -309,7 +334,7 @@ describe('Design overlay component', () => {
it.each`
element | getElementFunc | event
- ${'overlay'} | ${findOverlay} | ${'mouseleave'}
+ ${'overlay'} | ${() => wrapper} | ${'mouseleave'}
${'comment badge'} | ${findCommentBadge} | ${'mouseup'}
`(
'should emit `openCommentForm` event when $event fired on $element element',
diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js
index 7e513182589..d633d00f2ed 100644
--- a/spec/frontend/design_management/components/design_presentation_spec.js
+++ b/spec/frontend/design_management/components/design_presentation_spec.js
@@ -42,7 +42,7 @@ describe('Design management design presentation component', () => {
wrapper.element.scrollTo = jest.fn();
}
- const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment');
+ const findOverlayCommentButton = () => wrapper.find('[data-qa-selector="design_image_button"]');
/**
* Spy on $refs and mock given values
diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js
index e098e7de867..700faa8a70f 100644
--- a/spec/frontend/design_management/components/design_sidebar_spec.js
+++ b/spec/frontend/design_management/components/design_sidebar_spec.js
@@ -6,6 +6,10 @@ import Participants from '~/sidebar/components/participants/participants.vue';
import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
import design from '../mock_data/design';
import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql';
+import DesignTodoButton from '~/design_management/components/design_todo_button.vue';
+
+const scrollIntoViewMock = jest.fn();
+HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
const updateActiveDiscussionMutationVariables = {
mutation: updateActiveDiscussionMutation,
@@ -39,7 +43,7 @@ describe('Design management design sidebar component', () => {
const findNewDiscussionDisclaimer = () =>
wrapper.find('[data-testid="new-discussion-disclaimer"]');
- function createComponent(props = {}) {
+ function createComponent(props = {}, { enableTodoButton } = {}) {
wrapper = shallowMount(DesignSidebar, {
propsData: {
design,
@@ -53,6 +57,10 @@ describe('Design management design sidebar component', () => {
mutate,
},
},
+ stubs: { GlPopover },
+ provide: {
+ glFeatures: { designManagementTodoButton: enableTodoButton },
+ },
});
}
@@ -146,22 +154,22 @@ describe('Design management design sidebar component', () => {
});
it('emits correct event on discussion create note error', () => {
- findFirstDiscussion().vm.$emit('createNoteError', 'payload');
+ findFirstDiscussion().vm.$emit('create-note-error', 'payload');
expect(wrapper.emitted('onDesignDiscussionError')).toEqual([['payload']]);
});
it('emits correct event on discussion update note error', () => {
- findFirstDiscussion().vm.$emit('updateNoteError', 'payload');
+ findFirstDiscussion().vm.$emit('update-note-error', 'payload');
expect(wrapper.emitted('updateNoteError')).toEqual([['payload']]);
});
it('emits correct event on discussion resolve error', () => {
- findFirstDiscussion().vm.$emit('resolveDiscussionError', 'payload');
+ findFirstDiscussion().vm.$emit('resolve-discussion-error', 'payload');
expect(wrapper.emitted('resolveDiscussionError')).toEqual([['payload']]);
});
it('changes prop correctly on opening discussion form', () => {
- findFirstDiscussion().vm.$emit('openForm', 'some-id');
+ findFirstDiscussion().vm.$emit('open-form', 'some-id');
return wrapper.vm.$nextTick().then(() => {
expect(findFirstDiscussion().props('discussionWithOpenForm')).toBe('some-id');
@@ -220,6 +228,10 @@ describe('Design management design sidebar component', () => {
expect(findPopover().exists()).toBe(true);
});
+ it('scrolls to resolved threads link', () => {
+ expect(scrollIntoViewMock).toHaveBeenCalled();
+ });
+
it('dismisses a popover on the outside click', () => {
wrapper.trigger('click');
return wrapper.vm.$nextTick(() => {
@@ -233,4 +245,23 @@ describe('Design management design sidebar component', () => {
expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 });
});
});
+
+ it('does not render To-Do button by default', () => {
+ createComponent();
+ expect(wrapper.find(DesignTodoButton).exists()).toBe(false);
+ });
+
+ describe('when `design_management_todo_button` feature flag is enabled', () => {
+ beforeEach(() => {
+ createComponent({}, { enableTodoButton: true });
+ });
+
+ it('renders sidebar root element with no top padding', () => {
+ expect(wrapper.classes()).toContain('gl-pt-0');
+ });
+
+ it('renders To-Do button', () => {
+ expect(wrapper.find(DesignTodoButton).exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/design_management/components/design_todo_button_spec.js b/spec/frontend/design_management/components/design_todo_button_spec.js
new file mode 100644
index 00000000000..451c23f0fea
--- /dev/null
+++ b/spec/frontend/design_management/components/design_todo_button_spec.js
@@ -0,0 +1,158 @@
+import { shallowMount, mount } from '@vue/test-utils';
+import TodoButton from '~/vue_shared/components/todo_button.vue';
+import DesignTodoButton from '~/design_management/components/design_todo_button.vue';
+import createDesignTodoMutation from '~/design_management/graphql/mutations/create_design_todo.mutation.graphql';
+import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql';
+import mockDesign from '../mock_data/design';
+
+const mockDesignWithPendingTodos = {
+ ...mockDesign,
+ currentUserTodos: {
+ nodes: [
+ {
+ id: 'todo-id',
+ },
+ ],
+ },
+};
+
+const mutate = jest.fn().mockResolvedValue();
+
+describe('Design management design todo button', () => {
+ let wrapper;
+
+ function createComponent(props = {}, { mountFn = shallowMount } = {}) {
+ wrapper = mountFn(DesignTodoButton, {
+ propsData: {
+ design: mockDesign,
+ ...props,
+ },
+ provide: {
+ projectPath: 'project-path',
+ issueIid: '10',
+ },
+ mocks: {
+ $route: {
+ params: {
+ id: 'my-design.jpg',
+ },
+ query: {},
+ },
+ $apollo: {
+ mutate,
+ },
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ jest.clearAllMocks();
+ });
+
+ it('renders TodoButton component', () => {
+ expect(wrapper.find(TodoButton).exists()).toBe(true);
+ });
+
+ describe('when design has a pending todo', () => {
+ beforeEach(() => {
+ createComponent({ design: mockDesignWithPendingTodos }, { mountFn: mount });
+ });
+
+ it('renders correct button text', () => {
+ expect(wrapper.text()).toBe('Mark as done');
+ });
+
+ describe('when clicked', () => {
+ let dispatchEventSpy;
+
+ beforeEach(() => {
+ dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
+ jest.spyOn(document, 'querySelector').mockReturnValue({
+ innerText: 2,
+ });
+
+ createComponent({ design: mockDesignWithPendingTodos }, { mountFn: mount });
+ wrapper.trigger('click');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('calls `$apollo.mutate` with the `todoMarkDone` mutation and variables containing `id`', async () => {
+ const todoMarkDoneMutationVariables = {
+ mutation: todoMarkDoneMutation,
+ update: expect.anything(),
+ variables: {
+ id: 'todo-id',
+ },
+ };
+
+ expect(mutate).toHaveBeenCalledTimes(1);
+ expect(mutate).toHaveBeenCalledWith(todoMarkDoneMutationVariables);
+ });
+
+ it('calls dispatchDocumentEvent to update global To-Do counter correctly', () => {
+ const dispatchedEvent = dispatchEventSpy.mock.calls[0][0];
+
+ expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
+ expect(dispatchedEvent.detail).toEqual({ count: 1 });
+ expect(dispatchedEvent.type).toBe('todo:toggle');
+ });
+ });
+ });
+
+ describe('when design has no pending todos', () => {
+ beforeEach(() => {
+ createComponent({}, { mountFn: mount });
+ });
+
+ it('renders correct button text', () => {
+ expect(wrapper.text()).toBe('Add a To-Do');
+ });
+
+ describe('when clicked', () => {
+ let dispatchEventSpy;
+
+ beforeEach(() => {
+ dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
+ jest.spyOn(document, 'querySelector').mockReturnValue({
+ innerText: 2,
+ });
+
+ createComponent({}, { mountFn: mount });
+ wrapper.trigger('click');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('calls `$apollo.mutate` with the `createDesignTodoMutation` mutation and variables containing `issuable_id`, `issue_id`, & `projectPath`', async () => {
+ const createDesignTodoMutationVariables = {
+ mutation: createDesignTodoMutation,
+ update: expect.anything(),
+ variables: {
+ atVersion: null,
+ filenames: ['my-design.jpg'],
+ designId: '1',
+ issueId: '1',
+ issueIid: '10',
+ projectPath: 'project-path',
+ },
+ };
+
+ expect(mutate).toHaveBeenCalledTimes(1);
+ expect(mutate).toHaveBeenCalledWith(createDesignTodoMutationVariables);
+ });
+
+ it('calls dispatchDocumentEvent to update global To-Do counter correctly', () => {
+ const dispatchedEvent = dispatchEventSpy.mock.calls[0][0];
+
+ expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
+ expect(dispatchedEvent.detail).toEqual({ count: 3 });
+ expect(dispatchedEvent.type).toBe('todo:toggle');
+ });
+ });
+ });
+});
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 d76b6e712fe..822df1f6472 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
@@ -10,11 +10,11 @@ exports[`Design management list item component when item appears in view after i
exports[`Design management list item component with notes renders item with multiple comments 1`] = `
<router-link-stub
- class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new"
+ class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new"
to="[object Object]"
>
<div
- class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative"
>
<!---->
@@ -23,7 +23,7 @@ exports[`Design management list item component with notes renders item with mult
<img
alt="test"
- class="block mx-auto mw-100 mh-100 design-img"
+ class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img"
data-qa-selector="design_image"
src=""
/>
@@ -31,13 +31,13 @@ exports[`Design management list item component with notes renders item with mult
</div>
<div
- class="card-footer d-flex w-100"
+ class="card-footer gl-display-flex gl-w-full"
>
<div
- class="d-flex flex-column str-truncated-100"
+ class="gl-display-flex gl-flex-direction-column str-truncated-100"
>
<span
- class="bold str-truncated-100"
+ class="gl-font-weight-bold str-truncated-100"
data-qa-selector="design_file_name"
>
test
@@ -57,17 +57,17 @@ exports[`Design management list item component with notes renders item with mult
</div>
<div
- class="ml-auto d-flex align-items-center text-secondary"
+ class="gl-ml-auto gl-display-flex gl-align-items-center gl-text-gray-500"
>
- <icon-stub
- class="ml-1"
+ <gl-icon-stub
+ class="gl-ml-2"
name="comments"
size="16"
/>
<span
aria-label="2 comments"
- class="ml-1"
+ class="gl-ml-2"
>
2
@@ -80,11 +80,11 @@ exports[`Design management list item component with notes renders item with mult
exports[`Design management list item component with notes renders item with single comment 1`] = `
<router-link-stub
- class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new"
+ class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new"
to="[object Object]"
>
<div
- class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative"
>
<!---->
@@ -93,7 +93,7 @@ exports[`Design management list item component with notes renders item with sing
<img
alt="test"
- class="block mx-auto mw-100 mh-100 design-img"
+ class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img"
data-qa-selector="design_image"
src=""
/>
@@ -101,13 +101,13 @@ exports[`Design management list item component with notes renders item with sing
</div>
<div
- class="card-footer d-flex w-100"
+ class="card-footer gl-display-flex gl-w-full"
>
<div
- class="d-flex flex-column str-truncated-100"
+ class="gl-display-flex gl-flex-direction-column str-truncated-100"
>
<span
- class="bold str-truncated-100"
+ class="gl-font-weight-bold str-truncated-100"
data-qa-selector="design_file_name"
>
test
@@ -127,17 +127,17 @@ exports[`Design management list item component with notes renders item with sing
</div>
<div
- class="ml-auto d-flex align-items-center text-secondary"
+ class="gl-ml-auto gl-display-flex gl-align-items-center gl-text-gray-500"
>
- <icon-stub
- class="ml-1"
+ <gl-icon-stub
+ class="gl-ml-2"
name="comments"
size="16"
/>
<span
aria-label="1 comment"
- class="ml-1"
+ class="gl-ml-2"
>
1
diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js
index d1c90bd57b0..55c6ecbc26b 100644
--- a/spec/frontend/design_management/components/list/item_spec.js
+++ b/spec/frontend/design_management/components/list/item_spec.js
@@ -1,7 +1,6 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import VueRouter from 'vue-router';
-import Icon from '~/vue_shared/components/icon.vue';
import Item from '~/design_management/components/list/item.vue';
const localVue = createLocalVue();
@@ -20,7 +19,7 @@ describe('Design management list item component', () => {
let wrapper;
const findDesignEvent = () => wrapper.find('[data-testid="designEvent"]');
- const findEventIcon = () => findDesignEvent().find(Icon);
+ const findEventIcon = () => findDesignEvent().find(GlIcon);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
function createComponent({
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
index d6fd09eb698..1e94e90c3b0 100644
--- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
+++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management design version dropdown component renders design version dropdown button 1`] = `
-<gl-new-dropdown-stub
+<gl-dropdown-stub
category="tertiary"
headertext=""
issueiid=""
@@ -10,7 +10,7 @@ exports[`Design management design version dropdown component renders design vers
text="Showing latest version"
variant="default"
>
- <gl-new-dropdown-item-stub
+ <gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
@@ -22,8 +22,8 @@ exports[`Design management design version dropdown component renders design vers
Version
2
(latest)
- </gl-new-dropdown-item-stub>
- <gl-new-dropdown-item-stub
+ </gl-dropdown-item-stub>
+ <gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
@@ -34,12 +34,12 @@ exports[`Design management design version dropdown component renders design vers
Version
1
- </gl-new-dropdown-item-stub>
-</gl-new-dropdown-stub>
+ </gl-dropdown-item-stub>
+</gl-dropdown-stub>
`;
exports[`Design management design version dropdown component renders design version list 1`] = `
-<gl-new-dropdown-stub
+<gl-dropdown-stub
category="tertiary"
headertext=""
issueiid=""
@@ -48,7 +48,7 @@ exports[`Design management design version dropdown component renders design vers
text="Showing latest version"
variant="default"
>
- <gl-new-dropdown-item-stub
+ <gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
@@ -60,8 +60,8 @@ exports[`Design management design version dropdown component renders design vers
Version
2
(latest)
- </gl-new-dropdown-item-stub>
- <gl-new-dropdown-item-stub
+ </gl-dropdown-item-stub>
+ <gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
@@ -72,6 +72,6 @@ exports[`Design management design version dropdown component renders design vers
Version
1
- </gl-new-dropdown-item-stub>
-</gl-new-dropdown-stub>
+ </gl-dropdown-item-stub>
+</gl-dropdown-stub>
`;
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 f4206cdaeb3..4ef787ac754 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,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlNewDropdown, GlNewDropdownItem, GlSprintf } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue';
import mockAllVersions from './mock_data/all_versions';
@@ -42,7 +42,7 @@ describe('Design management design version dropdown component', () => {
wrapper.destroy();
});
- const findVersionLink = index => wrapper.findAll(GlNewDropdownItem).at(index);
+ const findVersionLink = index => wrapper.findAll(GlDropdownItem).at(index);
it('renders design version dropdown button', () => {
createComponent();
@@ -75,7 +75,7 @@ describe('Design management design version dropdown component', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlNewDropdown).attributes('text')).toBe('Showing latest version');
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing latest version');
});
});
@@ -83,7 +83,7 @@ describe('Design management design version dropdown component', () => {
createComponent({ maxVersions: 1 });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlNewDropdown).attributes('text')).toBe('Showing latest version');
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing latest version');
});
});
@@ -91,7 +91,7 @@ describe('Design management design version dropdown component', () => {
createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlNewDropdown).attributes('text')).toBe(`Showing version #1`);
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe(`Showing version #1`);
});
});
@@ -99,7 +99,7 @@ describe('Design management design version dropdown component', () => {
createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlNewDropdown).attributes('text')).toBe('Showing latest version');
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing latest version');
});
});
@@ -107,7 +107,7 @@ describe('Design management design version dropdown component', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.findAll(GlNewDropdownItem)).toHaveLength(wrapper.vm.allVersions.length);
+ expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length);
});
});
});
diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js
index 5e2df3877a5..1c7806c292f 100644
--- a/spec/frontend/design_management/mock_data/apollo_mock.js
+++ b/spec/frontend/design_management/mock_data/apollo_mock.js
@@ -13,6 +13,9 @@ export const designListQueryResponse = {
notesCount: 3,
image: 'image-1',
imageV432x230: 'image-1',
+ currentUserTodos: {
+ nodes: [],
+ },
},
{
id: '2',
@@ -21,6 +24,9 @@ export const designListQueryResponse = {
notesCount: 2,
image: 'image-2',
imageV432x230: 'image-2',
+ currentUserTodos: {
+ nodes: [],
+ },
},
{
id: '3',
@@ -29,6 +35,9 @@ export const designListQueryResponse = {
notesCount: 1,
image: 'image-3',
imageV432x230: 'image-3',
+ currentUserTodos: {
+ nodes: [],
+ },
},
],
},
@@ -60,6 +69,9 @@ export const reorderedDesigns = [
notesCount: 2,
image: 'image-2',
imageV432x230: 'image-2',
+ currentUserTodos: {
+ nodes: [],
+ },
},
{
id: '1',
@@ -68,6 +80,9 @@ export const reorderedDesigns = [
notesCount: 3,
image: 'image-1',
imageV432x230: 'image-1',
+ currentUserTodos: {
+ nodes: [],
+ },
},
{
id: '3',
@@ -76,6 +91,9 @@ export const reorderedDesigns = [
notesCount: 1,
image: 'image-3',
imageV432x230: 'image-3',
+ currentUserTodos: {
+ nodes: [],
+ },
},
];
diff --git a/spec/frontend/design_management/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js
index 72be33fef1d..f2a3a800969 100644
--- a/spec/frontend/design_management/mock_data/design.js
+++ b/spec/frontend/design_management/mock_data/design.js
@@ -1,5 +1,5 @@
export default {
- id: 'design-id',
+ id: 'gid::/gitlab/Design/1',
filename: 'test.jpg',
fullPath: 'full-design-path',
image: 'test.jpg',
@@ -8,6 +8,7 @@ export default {
name: 'test',
},
issue: {
+ id: 'gid::/gitlab/Issue/1',
title: 'My precious issue',
webPath: 'full-issue-path',
webUrl: 'full-issue-url',
diff --git a/spec/frontend/design_management/mock_data/discussion.js b/spec/frontend/design_management/mock_data/discussion.js
new file mode 100644
index 00000000000..fbf9a2fdcc1
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/discussion.js
@@ -0,0 +1,45 @@
+export default {
+ id: 'discussion-id-1',
+ resolved: false,
+ resolvable: true,
+ notes: [
+ {
+ id: 'note-id-1',
+ index: 1,
+ position: {
+ height: 100,
+ width: 100,
+ x: 10,
+ y: 15,
+ },
+ author: {
+ name: 'John',
+ webUrl: 'link-to-john-profile',
+ },
+ createdAt: '2020-05-08T07:10:45Z',
+ userPermissions: {
+ adminNote: true,
+ },
+ resolved: false,
+ },
+ {
+ id: 'note-id-3',
+ index: 3,
+ position: {
+ height: 50,
+ width: 50,
+ x: 25,
+ y: 25,
+ },
+ author: {
+ name: 'Mary',
+ webUrl: 'link-to-mary-profile',
+ },
+ createdAt: '2020-05-08T07:10:45Z',
+ userPermissions: {
+ adminNote: true,
+ },
+ resolved: false,
+ },
+ ],
+};
diff --git a/spec/frontend/design_management/mock_data/notes.js b/spec/frontend/design_management/mock_data/notes.js
index 80cb3944786..41cefaca05b 100644
--- a/spec/frontend/design_management/mock_data/notes.js
+++ b/spec/frontend/design_management/mock_data/notes.js
@@ -1,46 +1,44 @@
+import DISCUSSION_1 from './discussion';
+
+const DISCUSSION_2 = {
+ id: 'discussion-id-2',
+ notes: {
+ nodes: [
+ {
+ id: 'note-id-2',
+ index: 2,
+ position: {
+ height: 50,
+ width: 50,
+ x: 25,
+ y: 25,
+ },
+ author: {
+ name: 'Mary',
+ webUrl: 'link-to-mary-profile',
+ },
+ createdAt: '2020-05-08T07:10:45Z',
+ userPermissions: {
+ adminNote: true,
+ },
+ resolved: true,
+ },
+ ],
+ },
+};
+
export default [
{
- id: 'note-id-1',
- index: 1,
- position: {
- height: 100,
- width: 100,
- x: 10,
- y: 15,
- },
- author: {
- name: 'John',
- webUrl: 'link-to-john-profile',
- },
- createdAt: '2020-05-08T07:10:45Z',
- userPermissions: {
- adminNote: true,
- },
+ ...DISCUSSION_1.notes[0],
discussion: {
- id: 'discussion-id-1',
+ id: DISCUSSION_1.id,
+ notes: {
+ nodes: DISCUSSION_1.notes,
+ },
},
- resolved: false,
},
{
- id: 'note-id-2',
- index: 2,
- position: {
- height: 50,
- width: 50,
- x: 25,
- y: 25,
- },
- author: {
- name: 'Mary',
- webUrl: 'link-to-mary-profile',
- },
- createdAt: '2020-05-08T07:10:45Z',
- userPermissions: {
- adminNote: true,
- },
- discussion: {
- id: 'discussion-id-2',
- },
- resolved: true,
+ ...DISCUSSION_2.notes.nodes[0],
+ discussion: DISCUSSION_2,
},
];
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 3881b2d7679..b80b7fdb43e 100644
--- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
@@ -111,7 +111,7 @@ exports[`Design management index page designs renders designs list and header wi
>
<gl-button-stub
category="primary"
- class="gl-mr-3 js-select-all"
+ class="gl-mr-4 js-select-all"
icon=""
size="small"
variant="link"
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 823294efc38..c849e4d4ed6 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
@@ -32,6 +32,8 @@ exports[`Design management design index page renders design index 1`] = `
<div
class="image-notes"
>
+ <!---->
+
<h2
class="gl-font-weight-bold gl-mt-0"
>
@@ -57,11 +59,11 @@ exports[`Design management design index page renders design index 1`] = `
<design-discussion-stub
data-testid="unresolved-discussion"
- designid="test"
+ designid="gid::/gitlab/Design/1"
discussion="[object Object]"
discussionwithopenform=""
markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
- noteableid="design-id"
+ noteableid="gid::/gitlab/Design/1"
/>
<gl-button-stub
@@ -92,7 +94,7 @@ exports[`Design management design index page renders design index 1`] = `
</p>
<a
- href="#"
+ href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
rel="noopener noreferrer"
target="_blank"
>
@@ -105,11 +107,11 @@ exports[`Design management design index page renders design index 1`] = `
>
<design-discussion-stub
data-testid="resolved-discussion"
- designid="test"
+ designid="gid::/gitlab/Design/1"
discussion="[object Object]"
discussionwithopenform=""
markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
- noteableid="design-id"
+ noteableid="gid::/gitlab/Design/1"
/>
</gl-collapse-stub>
@@ -179,6 +181,8 @@ exports[`Design management design index page with error GlAlert is rendered in c
<div
class="image-notes"
>
+ <!---->
+
<h2
class="gl-font-weight-bold gl-mt-0"
>
diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js
index 369c8667f4d..d9f7146d258 100644
--- a/spec/frontend/design_management/pages/design/index_spec.js
+++ b/spec/frontend/design_management/pages/design/index_spec.js
@@ -7,24 +7,21 @@ import DesignIndex from '~/design_management/pages/design/index.vue';
import DesignSidebar from '~/design_management/components/design_sidebar.vue';
import DesignPresentation from '~/design_management/components/design_presentation.vue';
import createImageDiffNoteMutation from '~/design_management/graphql/mutations/create_image_diff_note.mutation.graphql';
-import design from '../../mock_data/design';
-import mockResponseWithDesigns from '../../mock_data/designs';
-import mockResponseNoDesigns from '../../mock_data/no_designs';
-import mockAllVersions from '../../mock_data/all_versions';
+import updateActiveDiscussion from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql';
import {
DESIGN_NOT_FOUND_ERROR,
DESIGN_VERSION_NOT_EXIST_ERROR,
} from '~/design_management/utils/error_messages';
-import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
+import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from '~/design_management/router/constants';
import createRouter from '~/design_management/router';
import * as utils from '~/design_management/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants';
+import design from '../../mock_data/design';
+import mockResponseWithDesigns from '../../mock_data/designs';
+import mockResponseNoDesigns from '../../mock_data/no_designs';
+import mockAllVersions from '../../mock_data/all_versions';
jest.mock('~/flash');
-jest.mock('mousetrap', () => ({
- bind: jest.fn(),
- unbind: jest.fn(),
-}));
const focusInput = jest.fn();
@@ -34,6 +31,12 @@ const DesignReplyForm = {
focusInput,
},
};
+const mockDesignNoDiscussions = {
+ ...design,
+ discussions: {
+ nodes: [],
+ },
+};
const localVue = createLocalVue();
localVue.use(VueRouter);
@@ -75,7 +78,7 @@ describe('Design management design index page', () => {
const findSidebar = () => wrapper.find(DesignSidebar);
const findDesignPresentation = () => wrapper.find(DesignPresentation);
- function createComponent(loading = false, data = {}) {
+ function createComponent({ loading = false } = {}, { data = {}, intialRouteOptions = {} } = {}) {
const $apollo = {
queries: {
design: {
@@ -87,6 +90,8 @@ describe('Design management design index page', () => {
router = createRouter();
+ router.push({ name: DESIGN_ROUTE_NAME, params: { id: design.id }, ...intialRouteOptions });
+
wrapper = shallowMount(DesignIndex, {
propsData: { id: '1' },
mocks: { $apollo },
@@ -126,29 +131,28 @@ describe('Design management design index page', () => {
},
};
jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockEl);
- createComponent(true);
+ createComponent({ loading: true });
- wrapper.vm.$router.push('/designs/test');
expect(mockEl.classList.add).toHaveBeenCalledTimes(1);
expect(mockEl.classList.add).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
});
});
it('sets loading state', () => {
- createComponent(true);
+ createComponent({ loading: true });
expect(wrapper.element).toMatchSnapshot();
});
it('renders design index', () => {
- createComponent(false, { design });
+ createComponent({ loading: false }, { data: { design } });
expect(wrapper.element).toMatchSnapshot();
expect(wrapper.find(GlAlert).exists()).toBe(false);
});
it('passes correct props to sidebar component', () => {
- createComponent(false, { design });
+ createComponent({ loading: false }, { data: { design } });
expect(findSidebar().props()).toEqual({
design,
@@ -158,14 +162,14 @@ describe('Design management design index page', () => {
});
it('opens a new discussion form', () => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
+ createComponent(
+ { loading: false },
+ {
+ data: {
+ design,
},
},
- });
+ );
findDesignPresentation().vm.$emit('openCommentForm', { x: 0, y: 0 });
@@ -175,15 +179,15 @@ describe('Design management design index page', () => {
});
it('keeps new discussion form focused', () => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
+ createComponent(
+ { loading: false },
+ {
+ data: {
+ design,
+ annotationCoordinates,
},
},
- annotationCoordinates,
- });
+ );
findDesignPresentation().vm.$emit('openCommentForm', { x: 10, y: 10 });
@@ -191,18 +195,18 @@ describe('Design management design index page', () => {
});
it('sends a mutation on submitting form and closes form', () => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
+ createComponent(
+ { loading: false },
+ {
+ data: {
+ design,
+ annotationCoordinates,
+ comment: newComment,
},
},
- annotationCoordinates,
- comment: newComment,
- });
+ );
- findDiscussionForm().vm.$emit('submitForm');
+ findDiscussionForm().vm.$emit('submit-form');
expect(mutate).toHaveBeenCalledWith(createDiscussionMutationVariables);
return wrapper.vm
@@ -216,18 +220,18 @@ describe('Design management design index page', () => {
});
it('closes the form and clears the comment on canceling form', () => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
+ createComponent(
+ { loading: false },
+ {
+ data: {
+ design,
+ annotationCoordinates,
+ comment: newComment,
},
},
- annotationCoordinates,
- comment: newComment,
- });
+ );
- findDiscussionForm().vm.$emit('cancelForm');
+ findDiscussionForm().vm.$emit('cancel-form');
expect(wrapper.vm.comment).toBe('');
@@ -238,15 +242,15 @@ describe('Design management design index page', () => {
describe('with error', () => {
beforeEach(() => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
+ createComponent(
+ { loading: false },
+ {
+ data: {
+ design: mockDesignNoDiscussions,
+ errorMessage: 'woops',
},
},
- errorMessage: 'woops',
- });
+ );
});
it('GlAlert is rendered in correct position with correct content', () => {
@@ -257,7 +261,7 @@ describe('Design management design index page', () => {
describe('onDesignQueryResult', () => {
describe('with no designs', () => {
it('redirects to /designs', () => {
- createComponent(true);
+ createComponent({ loading: true });
router.push = jest.fn();
wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false });
@@ -272,7 +276,7 @@ describe('Design management design index page', () => {
describe('when no design exists for given version', () => {
it('redirects to /designs', () => {
- createComponent(true);
+ createComponent({ loading: true });
wrapper.setData({
allVersions: mockAllVersions,
});
@@ -291,4 +295,24 @@ describe('Design management design index page', () => {
});
});
});
+
+ describe('when hash present in current route', () => {
+ it('calls updateActiveDiscussion mutation', () => {
+ createComponent(
+ { loading: false },
+ {
+ data: {
+ design,
+ },
+ intialRouteOptions: { hash: '#note_123' },
+ },
+ );
+
+ expect(mutate).toHaveBeenCalledTimes(1);
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: updateActiveDiscussion,
+ variables: { id: 'gid://gitlab/DiffNote/123', source: 'url' },
+ });
+ });
+ });
});
diff --git a/spec/frontend/design_management/pages/index_apollo_spec.js b/spec/frontend/design_management/pages/index_apollo_spec.js
deleted file mode 100644
index 3ea711c2cfa..00000000000
--- a/spec/frontend/design_management/pages/index_apollo_spec.js
+++ /dev/null
@@ -1,162 +0,0 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { createMockClient } from 'mock-apollo-client';
-import VueApollo from 'vue-apollo';
-import VueRouter from 'vue-router';
-import VueDraggable from 'vuedraggable';
-import { InMemoryCache } from 'apollo-cache-inmemory';
-import Design from '~/design_management/components/list/item.vue';
-import createRouter from '~/design_management/router';
-import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
-import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
-import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-import Index from '~/design_management/pages/index.vue';
-import {
- designListQueryResponse,
- permissionsQueryResponse,
- moveDesignMutationResponse,
- reorderedDesigns,
- moveDesignMutationResponseWithErrors,
-} from '../mock_data/apollo_mock';
-
-jest.mock('~/flash');
-
-const localVue = createLocalVue();
-localVue.use(VueApollo);
-
-const router = createRouter();
-localVue.use(VueRouter);
-
-const designToMove = {
- __typename: 'Design',
- id: '2',
- event: 'NONE',
- filename: 'fox_2.jpg',
- notesCount: 2,
- image: 'image-2',
- imageV432x230: 'image-2',
-};
-
-describe('Design management index page with Apollo mock', () => {
- let wrapper;
- let mockClient;
- let apolloProvider;
- let moveDesignHandler;
-
- async function moveDesigns(localWrapper) {
- await jest.runOnlyPendingTimers();
- await localWrapper.vm.$nextTick();
-
- localWrapper.find(VueDraggable).vm.$emit('input', reorderedDesigns);
- localWrapper.find(VueDraggable).vm.$emit('change', {
- moved: {
- newIndex: 0,
- element: designToMove,
- },
- });
- }
-
- const fragmentMatcher = { match: () => true };
-
- const cache = new InMemoryCache({
- fragmentMatcher,
- addTypename: false,
- });
-
- const findDesigns = () => wrapper.findAll(Design);
-
- function createComponent({
- moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
- }) {
- mockClient = createMockClient({ cache });
-
- mockClient.setRequestHandler(
- getDesignListQuery,
- jest.fn().mockResolvedValue(designListQueryResponse),
- );
-
- mockClient.setRequestHandler(
- permissionsQuery,
- jest.fn().mockResolvedValue(permissionsQueryResponse),
- );
-
- moveDesignHandler = moveHandler;
-
- mockClient.setRequestHandler(moveDesignMutation, moveDesignHandler);
-
- apolloProvider = new VueApollo({
- defaultClient: mockClient,
- });
-
- wrapper = shallowMount(Index, {
- localVue,
- apolloProvider,
- router,
- stubs: { VueDraggable },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- mockClient = null;
- apolloProvider = null;
- });
-
- it('has a design with id 1 as a first one', async () => {
- createComponent({});
-
- await jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
-
- expect(findDesigns()).toHaveLength(3);
- expect(
- findDesigns()
- .at(0)
- .props('id'),
- ).toBe('1');
- });
-
- it('calls a mutation with correct parameters and reorders designs', async () => {
- createComponent({});
-
- await moveDesigns(wrapper);
-
- expect(moveDesignHandler).toHaveBeenCalled();
-
- await wrapper.vm.$nextTick();
-
- expect(
- findDesigns()
- .at(0)
- .props('id'),
- ).toBe('2');
- });
-
- it('displays flash if mutation had a recoverable error', async () => {
- createComponent({
- moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors),
- });
-
- await moveDesigns(wrapper);
-
- await wrapper.vm.$nextTick();
-
- expect(createFlash).toHaveBeenCalledWith('Houston, we have a problem');
- });
-
- it('displays flash if mutation had a non-recoverable error', async () => {
- createComponent({
- moveHandler: jest.fn().mockRejectedValue('Error'),
- });
-
- await moveDesigns(wrapper);
-
- await jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
-
- expect(createFlash).toHaveBeenCalledWith(
- 'Something went wrong when reordering designs. Please try again',
- );
- });
-});
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index 093fa155d2e..661717d29a3 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -1,13 +1,15 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { ApolloMutation } from 'vue-apollo';
+import VueApollo, { ApolloMutation } from 'vue-apollo';
import VueDraggable from 'vuedraggable';
import VueRouter from 'vue-router';
import { GlEmptyState } from '@gitlab/ui';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
import Index from '~/design_management/pages/index.vue';
import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql';
import DesignDestroyer from '~/design_management/components/design_destroyer.vue';
import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue';
import DeleteButton from '~/design_management/components/delete_button.vue';
+import Design from '~/design_management/components/list/item.vue';
import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
import {
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
@@ -17,6 +19,16 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import createRouter from '~/design_management/router';
import * as utils from '~/design_management/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants';
+import {
+ designListQueryResponse,
+ permissionsQueryResponse,
+ moveDesignMutationResponse,
+ reorderedDesigns,
+ moveDesignMutationResponseWithErrors,
+} from '../mock_data/apollo_mock';
+import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
+import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
+import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
jest.mock('~/flash.js');
const mockPageEl = {
@@ -61,9 +73,21 @@ const mockVersion = {
id: 'gid://gitlab/DesignManagement::Version/1',
};
+const designToMove = {
+ __typename: 'Design',
+ id: '2',
+ event: 'NONE',
+ filename: 'fox_2.jpg',
+ notesCount: 2,
+ image: 'image-2',
+ imageV432x230: 'image-2',
+};
+
describe('Design management index page', () => {
let mutate;
let wrapper;
+ let fakeApollo;
+ let moveDesignHandler;
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
const findSelectAllButton = () => wrapper.find('.js-select-all');
@@ -74,6 +98,20 @@ describe('Design management index page', () => {
const findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]');
const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]');
+ const findDesigns = () => wrapper.findAll(Design);
+
+ async function moveDesigns(localWrapper) {
+ await jest.runOnlyPendingTimers();
+ await localWrapper.vm.$nextTick();
+
+ localWrapper.find(VueDraggable).vm.$emit('input', reorderedDesigns);
+ localWrapper.find(VueDraggable).vm.$emit('change', {
+ moved: {
+ newIndex: 0,
+ element: designToMove,
+ },
+ });
+ }
function createComponent({
loading = false,
@@ -118,8 +156,30 @@ describe('Design management index page', () => {
});
}
+ function createComponentWithApollo({
+ moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
+ }) {
+ localVue.use(VueApollo);
+ moveDesignHandler = moveHandler;
+
+ const requestHandlers = [
+ [getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
+ [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
+ [moveDesignMutation, moveDesignHandler],
+ ];
+
+ fakeApollo = createMockApollo(requestHandlers);
+ wrapper = shallowMount(Index, {
+ localVue,
+ apolloProvider: fakeApollo,
+ router,
+ stubs: { VueDraggable },
+ });
+ }
+
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
});
describe('designs', () => {
@@ -478,16 +538,15 @@ describe('Design management index page', () => {
describe('on non-latest version', () => {
beforeEach(() => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+ });
- router.replace({
+ it('does not render design checkboxes', async () => {
+ await router.replace({
name: DESIGNS_ROUTE_NAME,
query: {
version: '2',
},
});
- });
-
- it('does not render design checkboxes', () => {
expect(findDesignCheckboxes()).toHaveLength(0);
});
@@ -514,13 +573,6 @@ describe('Design management index page', () => {
files: [{ name: 'image.png', type: 'image/png' }],
getData: () => 'test.png',
};
-
- router.replace({
- name: DESIGNS_ROUTE_NAME,
- query: {
- version: '2',
- },
- });
});
it('does not call paste event if designs wrapper is not hovered', () => {
@@ -587,7 +639,69 @@ describe('Design management index page', () => {
});
createComponent(true);
- expect(scrollIntoViewMock).toHaveBeenCalled();
+ return wrapper.vm.$nextTick().then(() => {
+ expect(scrollIntoViewMock).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('with mocked Apollo client', () => {
+ it('has a design with id 1 as a first one', async () => {
+ createComponentWithApollo({});
+
+ await jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+
+ expect(findDesigns()).toHaveLength(3);
+ expect(
+ findDesigns()
+ .at(0)
+ .props('id'),
+ ).toBe('1');
+ });
+
+ it('calls a mutation with correct parameters and reorders designs', async () => {
+ createComponentWithApollo({});
+
+ await moveDesigns(wrapper);
+
+ expect(moveDesignHandler).toHaveBeenCalled();
+
+ await wrapper.vm.$nextTick();
+
+ expect(
+ findDesigns()
+ .at(0)
+ .props('id'),
+ ).toBe('2');
+ });
+
+ it('displays flash if mutation had a recoverable error', async () => {
+ createComponentWithApollo({
+ moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors),
+ });
+
+ await moveDesigns(wrapper);
+
+ await wrapper.vm.$nextTick();
+
+ expect(createFlash).toHaveBeenCalledWith('Houston, we have a problem');
+ });
+
+ it('displays flash if mutation had a non-recoverable error', async () => {
+ createComponentWithApollo({
+ moveHandler: jest.fn().mockRejectedValue('Error'),
+ });
+
+ await moveDesigns(wrapper);
+
+ await wrapper.vm.$nextTick(); // kick off the DOM update
+ await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
+ await wrapper.vm.$nextTick(); // kick off the DOM update for flash
+
+ expect(createFlash).toHaveBeenCalledWith(
+ 'Something went wrong when reordering designs. Please try again',
+ );
});
});
});
diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js
index 2b8c7ee959b..d4cb9f75a77 100644
--- a/spec/frontend/design_management/router_spec.js
+++ b/spec/frontend/design_management/router_spec.js
@@ -35,11 +35,6 @@ function factory(routeArg) {
});
}
-jest.mock('mousetrap', () => ({
- bind: jest.fn(),
- unbind: jest.fn(),
-}));
-
describe('Design management router', () => {
afterEach(() => {
window.location.hash = '';
diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js
index e8a5cf3949d..6c859e8c3e8 100644
--- a/spec/frontend/design_management/utils/cache_update_spec.js
+++ b/spec/frontend/design_management/utils/cache_update_spec.js
@@ -1,14 +1,12 @@
import { InMemoryCache } from 'apollo-cache-inmemory';
import {
updateStoreAfterDesignsDelete,
- updateStoreAfterAddDiscussionComment,
updateStoreAfterAddImageDiffNote,
updateStoreAfterUploadDesign,
updateStoreAfterUpdateImageDiffNote,
} from '~/design_management/utils/cache_update';
import {
designDeletionError,
- ADD_DISCUSSION_COMMENT_ERROR,
ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
} from '~/design_management/utils/error_messages';
@@ -28,12 +26,11 @@ describe('Design Management cache update', () => {
describe('error handling', () => {
it.each`
- fnName | subject | errorMessage | extraArgs
- ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]}
- ${'updateStoreAfterAddDiscussionComment'} | ${updateStoreAfterAddDiscussionComment} | ${ADD_DISCUSSION_COMMENT_ERROR} | ${[]}
- ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]}
- ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]}
- ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]}
+ fnName | subject | errorMessage | extraArgs
+ ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]}
+ ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]}
+ ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]}
+ ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]}
`('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => {
expect(createFlash).not.toHaveBeenCalled();
expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow();
diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js
index e6d836b9157..7e857d08d25 100644
--- a/spec/frontend/design_management/utils/design_management_utils_spec.js
+++ b/spec/frontend/design_management/utils/design_management_utils_spec.js
@@ -6,6 +6,7 @@ import {
updateImageDiffNoteOptimisticResponse,
isValidDesignFile,
extractDesign,
+ extractDesignNoteId,
} from '~/design_management/utils/design_management_utils';
import mockResponseNoDesigns from '../mock_data/no_designs';
import mockResponseWithDesigns from '../mock_data/designs';
@@ -171,3 +172,19 @@ describe('extractDesign', () => {
});
});
});
+
+describe('extractDesignNoteId', () => {
+ it.each`
+ hash | expectedNoteId
+ ${'#note_0'} | ${'0'}
+ ${'#note_1'} | ${'1'}
+ ${'#note_23'} | ${'23'}
+ ${'#note_456'} | ${'456'}
+ ${'note_1'} | ${null}
+ ${'#note_'} | ${null}
+ ${'#note_asd'} | ${null}
+ ${'#note_1asd'} | ${null}
+ `('returns $expectedNoteId when hash is $hash', ({ hash, expectedNoteId }) => {
+ expect(extractDesignNoteId(hash)).toBe(expectedNoteId);
+ });
+});
diff --git a/spec/frontend/design_management_legacy/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/design_note_pin_spec.js.snap
deleted file mode 100644
index 62a0f675cff..00000000000
--- a/spec/frontend/design_management_legacy/components/__snapshots__/design_note_pin_spec.js.snap
+++ /dev/null
@@ -1,42 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design note pin component should match the snapshot of note when repositioning 1`] = `
-<button
- aria-label="Comment form position"
- class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 btn-transparent comment-indicator"
- style="left: 10px; top: 10px; cursor: move;"
- type="button"
->
- <gl-icon-stub
- name="image-comment-dark"
- size="24"
- />
-</button>
-`;
-
-exports[`Design note pin component should match the snapshot of note with index 1`] = `
-<button
- aria-label="Comment '1' position"
- class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 js-image-badge badge badge-pill"
- style="left: 10px; top: 10px;"
- type="button"
->
-
- 1
-
-</button>
-`;
-
-exports[`Design note pin component should match the snapshot of note without index 1`] = `
-<button
- aria-label="Comment form position"
- class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 btn-transparent comment-indicator"
- style="left: 10px; top: 10px;"
- type="button"
->
- <gl-icon-stub
- name="image-comment-dark"
- size="24"
- />
-</button>
-`;
diff --git a/spec/frontend/design_management_legacy/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/design_presentation_spec.js.snap
deleted file mode 100644
index 189962c5b2e..00000000000
--- a/spec/frontend/design_management_legacy/components/__snapshots__/design_presentation_spec.js.snap
+++ /dev/null
@@ -1,104 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = `
-<div
- class="h-100 w-100 p-3 overflow-auto position-relative"
->
- <div
- class="h-100 w-100 d-flex align-items-center position-relative"
- >
- <design-image-stub
- image="test.jpg"
- name="test"
- scale="1"
- />
-
- <design-overlay-stub
- currentcommentform="[object Object]"
- dimensions="[object Object]"
- notes=""
- position="[object Object]"
- />
- </div>
-</div>
-`;
-
-exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = `
-<div
- class="h-100 w-100 p-3 overflow-auto position-relative"
->
- <div
- class="h-100 w-100 d-flex align-items-center position-relative"
- >
- <design-image-stub
- image="test.jpg"
- name="test"
- scale="1"
- />
-
- <design-overlay-stub
- dimensions="[object Object]"
- notes=""
- position="[object Object]"
- />
- </div>
-</div>
-`;
-
-exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = `
-<div
- class="h-100 w-100 p-3 overflow-auto position-relative"
->
- <div
- class="h-100 w-100 d-flex align-items-center position-relative"
- >
- <design-image-stub
- image="test.jpg"
- name="test"
- scale="1"
- />
-
- <design-overlay-stub
- dimensions="[object Object]"
- notes=""
- position="[object Object]"
- />
- </div>
-</div>
-`;
-
-exports[`Design management design presentation component renders empty state when no image provided 1`] = `
-<div
- class="h-100 w-100 p-3 overflow-auto position-relative"
->
- <div
- class="h-100 w-100 d-flex align-items-center position-relative"
- >
- <!---->
-
- <!---->
- </div>
-</div>
-`;
-
-exports[`Design management design presentation component renders image and overlay when image provided 1`] = `
-<div
- class="h-100 w-100 p-3 overflow-auto position-relative"
->
- <div
- class="h-100 w-100 d-flex align-items-center position-relative"
- >
- <design-image-stub
- image="test.jpg"
- name="test"
- scale="1"
- />
-
- <design-overlay-stub
- dimensions="[object Object]"
- notes=""
- position="[object Object]"
- />
- </div>
-</div>
-`;
diff --git a/spec/frontend/design_management_legacy/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/design_scaler_spec.js.snap
deleted file mode 100644
index cb4575cbd11..00000000000
--- a/spec/frontend/design_management_legacy/components/__snapshots__/design_scaler_spec.js.snap
+++ /dev/null
@@ -1,115 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = `
-<div
- class="design-scaler btn-group"
- role="group"
->
- <button
- class="btn"
- disabled="disabled"
- >
- <span
- class="d-flex-center gl-icon s16"
- >
-
- –
-
- </span>
- </button>
-
- <button
- class="btn"
- disabled="disabled"
- >
- <gl-icon-stub
- name="redo"
- size="16"
- />
- </button>
-
- <button
- class="btn"
- >
- <gl-icon-stub
- name="plus"
- size="16"
- />
- </button>
-</div>
-`;
-
-exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = `
-<div
- class="design-scaler btn-group"
- role="group"
->
- <button
- class="btn"
- >
- <span
- class="d-flex-center gl-icon s16"
- >
-
- –
-
- </span>
- </button>
-
- <button
- class="btn"
- >
- <gl-icon-stub
- name="redo"
- size="16"
- />
- </button>
-
- <button
- class="btn"
- >
- <gl-icon-stub
- name="plus"
- size="16"
- />
- </button>
-</div>
-`;
-
-exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = `
-<div
- class="design-scaler btn-group"
- role="group"
->
- <button
- class="btn"
- >
- <span
- class="d-flex-center gl-icon s16"
- >
-
- –
-
- </span>
- </button>
-
- <button
- class="btn"
- >
- <gl-icon-stub
- name="redo"
- size="16"
- />
- </button>
-
- <button
- class="btn"
- disabled="disabled"
- >
- <gl-icon-stub
- name="plus"
- size="16"
- />
- </button>
-</div>
-`;
diff --git a/spec/frontend/design_management_legacy/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/image_spec.js.snap
deleted file mode 100644
index acaa62b11eb..00000000000
--- a/spec/frontend/design_management_legacy/components/__snapshots__/image_spec.js.snap
+++ /dev/null
@@ -1,68 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management large image component renders image 1`] = `
-<div
- class="m-auto js-design-image"
->
- <!---->
-
- <img
- alt="test"
- class="mh-100 img-fluid"
- src="test.jpg"
- />
-</div>
-`;
-
-exports[`Design management large image component renders loading state 1`] = `
-<div
- class="m-auto js-design-image"
- isloading="true"
->
- <!---->
-
- <img
- alt=""
- class="mh-100 img-fluid"
- src=""
- />
-</div>
-`;
-
-exports[`Design management large image component renders media broken icon on error 1`] = `
-<gl-icon-stub
- class="text-secondary-100"
- name="media-broken"
- size="48"
-/>
-`;
-
-exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = `
-<div
- class="m-auto js-design-image"
->
- <!---->
-
- <img
- alt="test"
- class="mh-100"
- src="test.jpg"
- style="width: 100px; height: 100px;"
- />
-</div>
-`;
-
-exports[`Design management large image component zoom sets image style when zoomed 1`] = `
-<div
- class="m-auto js-design-image"
->
- <!---->
-
- <img
- alt="test"
- class="mh-100"
- src="test.jpg"
- style="width: 200px; height: 200px;"
- />
-</div>
-`;
diff --git a/spec/frontend/design_management_legacy/components/delete_button_spec.js b/spec/frontend/design_management_legacy/components/delete_button_spec.js
deleted file mode 100644
index 73b4908d06a..00000000000
--- a/spec/frontend/design_management_legacy/components/delete_button_spec.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
-import BatchDeleteButton from '~/design_management_legacy/components/delete_button.vue';
-
-describe('Batch delete button component', () => {
- let wrapper;
-
- const findButton = () => wrapper.find(GlDeprecatedButton);
- const findModal = () => wrapper.find(GlModal);
-
- function createComponent(isDeleting = false) {
- wrapper = shallowMount(BatchDeleteButton, {
- propsData: {
- isDeleting,
- },
- directives: {
- GlModalDirective,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders non-disabled button by default', () => {
- createComponent();
-
- expect(findButton().exists()).toBe(true);
- expect(findButton().attributes('disabled')).toBeFalsy();
- });
-
- it('renders disabled button when design is deleting', () => {
- createComponent(true);
- expect(findButton().attributes('disabled')).toBeTruthy();
- });
-
- it('emits `deleteSelectedDesigns` event on modal ok click', () => {
- createComponent();
- findButton().vm.$emit('click');
- return wrapper.vm
- .$nextTick()
- .then(() => {
- findModal().vm.$emit('ok');
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy();
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/design_note_pin_spec.js b/spec/frontend/design_management_legacy/components/design_note_pin_spec.js
deleted file mode 100644
index 3077928cf86..00000000000
--- a/spec/frontend/design_management_legacy/components/design_note_pin_spec.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import DesignNotePin from '~/design_management_legacy/components/design_note_pin.vue';
-
-describe('Design note pin component', () => {
- let wrapper;
-
- function createComponent(propsData = {}) {
- wrapper = shallowMount(DesignNotePin, {
- propsData: {
- position: {
- left: '10px',
- top: '10px',
- },
- ...propsData,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('should match the snapshot of note without index', () => {
- createComponent();
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('should match the snapshot of note with index', () => {
- createComponent({ label: 1 });
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('should match the snapshot of note when repositioning', () => {
- createComponent({ repositioning: true });
- expect(wrapper.element).toMatchSnapshot();
- });
-
- describe('pinStyle', () => {
- it('sets cursor to `move` when repositioning = true', () => {
- createComponent({ repositioning: true });
- expect(wrapper.vm.pinStyle.cursor).toBe('move');
- });
-
- it('does not set cursor when repositioning = false', () => {
- createComponent();
- expect(wrapper.vm.pinStyle.cursor).toBe(undefined);
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_note_spec.js.snap
deleted file mode 100644
index b55bacb6fc5..00000000000
--- a/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_note_spec.js.snap
+++ /dev/null
@@ -1,67 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design note component should match the snapshot 1`] = `
-<timeline-entry-item-stub
- class="design-note note-form"
- id="note_123"
->
- <user-avatar-link-stub
- imgalt=""
- imgcssclasses=""
- imgsize="40"
- imgsrc=""
- linkhref=""
- tooltipplacement="top"
- tooltiptext=""
- username=""
- />
-
- <div
- class="d-flex justify-content-between"
- >
- <div>
- <a
- class="js-user-link"
- data-user-id="author-id"
- >
- <span
- class="note-header-author-name bold"
- >
-
- </span>
-
- <!---->
-
- <span
- class="note-headline-light"
- >
- @
- </span>
- </a>
-
- <span
- class="note-headline-light note-headline-meta"
- >
- <span
- class="system-note-message"
- />
-
- <!---->
- </span>
- </div>
-
- <div
- class="gl-display-flex"
- >
-
- <!---->
- </div>
- </div>
-
- <div
- class="note-text js-note-text md"
- data-qa-selector="note_content"
- />
-
-</timeline-entry-item-stub>
-`;
diff --git a/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
deleted file mode 100644
index e01c79e3520..00000000000
--- a/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
+++ /dev/null
@@ -1,15 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = `
-"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
- <!---->
- Comment
-</button>"
-`;
-
-exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = `
-"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
- <!---->
- Save comment
-</button>"
-`;
diff --git a/spec/frontend/design_management_legacy/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management_legacy/components/design_notes/design_discussion_spec.js
deleted file mode 100644
index d20be97f470..00000000000
--- a/spec/frontend/design_management_legacy/components/design_notes/design_discussion_spec.js
+++ /dev/null
@@ -1,318 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
-import notes from '../../mock_data/notes';
-import DesignDiscussion from '~/design_management_legacy/components/design_notes/design_discussion.vue';
-import DesignNote from '~/design_management_legacy/components/design_notes/design_note.vue';
-import DesignReplyForm from '~/design_management_legacy/components/design_notes/design_reply_form.vue';
-import createNoteMutation from '~/design_management_legacy/graphql/mutations/create_note.mutation.graphql';
-import toggleResolveDiscussionMutation from '~/design_management_legacy/graphql/mutations/toggle_resolve_discussion.mutation.graphql';
-import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
-import ToggleRepliesWidget from '~/design_management_legacy/components/design_notes/toggle_replies_widget.vue';
-
-const discussion = {
- id: '0',
- resolved: false,
- resolvable: true,
- notes,
-};
-
-describe('Design discussions component', () => {
- let wrapper;
-
- const findDesignNotes = () => wrapper.findAll(DesignNote);
- const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder);
- const findReplyForm = () => wrapper.find(DesignReplyForm);
- const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget);
- const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]');
- const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]');
- const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]');
- const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon);
- const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]');
-
- const mutationVariables = {
- mutation: createNoteMutation,
- update: expect.anything(),
- variables: {
- input: {
- noteableId: 'noteable-id',
- body: 'test',
- discussionId: '0',
- },
- },
- };
- const mutate = jest.fn(() => Promise.resolve());
- const $apollo = {
- mutate,
- };
-
- function createComponent(props = {}, data = {}) {
- wrapper = mount(DesignDiscussion, {
- propsData: {
- resolvedDiscussionsExpanded: true,
- discussion,
- noteableId: 'noteable-id',
- designId: 'design-id',
- discussionIndex: 1,
- discussionWithOpenForm: '',
- ...props,
- },
- data() {
- return {
- ...data,
- };
- },
- mocks: {
- $apollo,
- $route: {
- hash: '#note_1',
- },
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when discussion is not resolvable', () => {
- beforeEach(() => {
- createComponent({
- discussion: {
- ...discussion,
- resolvable: false,
- },
- });
- });
-
- it('does not render an icon to resolve a thread', () => {
- expect(findResolveIcon().exists()).toBe(false);
- });
-
- it('does not render a checkbox in reply form', () => {
- findReplyPlaceholder().vm.$emit('onMouseDown');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findResolveCheckbox().exists()).toBe(false);
- });
- });
- });
-
- describe('when discussion is unresolved', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders correct amount of discussion notes', () => {
- expect(findDesignNotes()).toHaveLength(2);
- expect(findDesignNotes().wrappers.every(w => w.isVisible())).toBe(true);
- });
-
- it('renders reply placeholder', () => {
- expect(findReplyPlaceholder().isVisible()).toBe(true);
- });
-
- it('does not render toggle replies widget', () => {
- expect(findRepliesWidget().exists()).toBe(false);
- });
-
- it('renders a correct icon to resolve a thread', () => {
- expect(findResolveIcon().props('name')).toBe('check-circle');
- });
-
- it('renders a checkbox with Resolve thread text in reply form', () => {
- findReplyPlaceholder().vm.$emit('onClick');
- wrapper.setProps({ discussionWithOpenForm: discussion.id });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findResolveCheckbox().text()).toBe('Resolve thread');
- });
- });
-
- it('does not render resolved message', () => {
- expect(findResolvedMessage().exists()).toBe(false);
- });
- });
-
- describe('when discussion is resolved', () => {
- beforeEach(() => {
- createComponent({
- discussion: {
- ...discussion,
- resolved: true,
- resolvedBy: notes[0].author,
- resolvedAt: '2020-05-08T07:10:45Z',
- },
- });
- });
-
- it('shows only the first note', () => {
- expect(
- findDesignNotes()
- .at(0)
- .isVisible(),
- ).toBe(true);
- expect(
- findDesignNotes()
- .at(1)
- .isVisible(),
- ).toBe(false);
- });
-
- it('renders resolved message', () => {
- expect(findResolvedMessage().exists()).toBe(true);
- });
-
- it('does not show renders reply placeholder', () => {
- expect(findReplyPlaceholder().isVisible()).toBe(false);
- });
-
- it('renders toggle replies widget with correct props', () => {
- expect(findRepliesWidget().exists()).toBe(true);
- expect(findRepliesWidget().props()).toEqual({
- collapsed: true,
- replies: notes.slice(1),
- });
- });
-
- it('renders a correct icon to resolve a thread', () => {
- expect(findResolveIcon().props('name')).toBe('check-circle-filled');
- });
-
- describe('when replies are expanded', () => {
- beforeEach(() => {
- findRepliesWidget().vm.$emit('toggle');
- return wrapper.vm.$nextTick();
- });
-
- it('renders replies widget with collapsed prop equal to false', () => {
- expect(findRepliesWidget().props('collapsed')).toBe(false);
- });
-
- it('renders the second note', () => {
- expect(
- findDesignNotes()
- .at(1)
- .isVisible(),
- ).toBe(true);
- });
-
- it('renders a reply placeholder', () => {
- expect(findReplyPlaceholder().isVisible()).toBe(true);
- });
-
- it('renders a checkbox with Unresolve thread text in reply form', () => {
- findReplyPlaceholder().vm.$emit('onClick');
- wrapper.setProps({ discussionWithOpenForm: discussion.id });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findResolveCheckbox().text()).toBe('Unresolve thread');
- });
- });
- });
- });
-
- it('hides reply placeholder and opens form on placeholder click', () => {
- createComponent();
- findReplyPlaceholder().vm.$emit('onClick');
- wrapper.setProps({ discussionWithOpenForm: discussion.id });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findReplyPlaceholder().exists()).toBe(false);
- expect(findReplyForm().exists()).toBe(true);
- });
- });
-
- it('calls mutation on submitting form and closes the form', () => {
- createComponent(
- { discussionWithOpenForm: discussion.id },
- { discussionComment: 'test', isFormRendered: true },
- );
-
- findReplyForm().vm.$emit('submitForm');
- expect(mutate).toHaveBeenCalledWith(mutationVariables);
-
- return mutate()
- .then(() => {
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(findReplyForm().exists()).toBe(false);
- });
- });
-
- it('clears the discussion comment on closing comment form', () => {
- createComponent(
- { discussionWithOpenForm: discussion.id },
- { discussionComment: 'test', isFormRendered: true },
- );
-
- return wrapper.vm
- .$nextTick()
- .then(() => {
- findReplyForm().vm.$emit('cancelForm');
-
- expect(wrapper.vm.discussionComment).toBe('');
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(findReplyForm().exists()).toBe(false);
- });
- });
-
- it('applies correct class to design notes when discussion is highlighted', () => {
- createComponent(
- {},
- {
- activeDiscussion: {
- id: notes[0].id,
- source: 'pin',
- },
- },
- );
-
- expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe(
- true,
- );
- });
-
- it('calls toggleResolveDiscussion mutation on resolve thread button click', () => {
- createComponent();
- findResolveButton().trigger('click');
- expect(mutate).toHaveBeenCalledWith({
- mutation: toggleResolveDiscussionMutation,
- variables: {
- id: discussion.id,
- resolve: true,
- },
- });
- return wrapper.vm.$nextTick(() => {
- expect(findResolveLoadingIcon().exists()).toBe(true);
- });
- });
-
- it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => {
- createComponent(
- { discussionWithOpenForm: discussion.id },
- { discussionComment: 'test', isFormRendered: true },
- );
- findResolveButton().trigger('click');
- findReplyForm().vm.$emit('submitForm');
-
- return mutate().then(() => {
- expect(mutate).toHaveBeenCalledWith({
- mutation: toggleResolveDiscussionMutation,
- variables: {
- id: discussion.id,
- resolve: true,
- },
- });
- });
- });
-
- it('emits openForm event on opening the form', () => {
- createComponent();
- findReplyPlaceholder().vm.$emit('onClick');
-
- expect(wrapper.emitted('openForm')).toBeTruthy();
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/design_notes/design_note_spec.js b/spec/frontend/design_management_legacy/components/design_notes/design_note_spec.js
deleted file mode 100644
index aa187cd1388..00000000000
--- a/spec/frontend/design_management_legacy/components/design_notes/design_note_spec.js
+++ /dev/null
@@ -1,170 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { ApolloMutation } from 'vue-apollo';
-import DesignNote from '~/design_management_legacy/components/design_notes/design_note.vue';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import DesignReplyForm from '~/design_management_legacy/components/design_notes/design_reply_form.vue';
-
-const scrollIntoViewMock = jest.fn();
-const note = {
- id: 'gid://gitlab/DiffNote/123',
- author: {
- id: 'author-id',
- },
- body: 'test',
- userPermissions: {
- adminNote: false,
- },
-};
-HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
-
-const $route = {
- hash: '#note_123',
-};
-
-const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } });
-
-describe('Design note component', () => {
- let wrapper;
-
- const findUserAvatar = () => wrapper.find(UserAvatarLink);
- const findUserLink = () => wrapper.find('.js-user-link');
- const findReplyForm = () => wrapper.find(DesignReplyForm);
- const findEditButton = () => wrapper.find('.js-note-edit');
- const findNoteContent = () => wrapper.find('.js-note-text');
-
- function createComponent(props = {}, data = { isEditing: false }) {
- wrapper = shallowMount(DesignNote, {
- propsData: {
- note: {},
- ...props,
- },
- data() {
- return {
- ...data,
- };
- },
- mocks: {
- $route,
- $apollo: {
- mutate,
- },
- },
- stubs: {
- ApolloMutation,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('should match the snapshot', () => {
- createComponent({
- note,
- });
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('should render an author', () => {
- createComponent({
- note,
- });
-
- expect(findUserAvatar().exists()).toBe(true);
- expect(findUserLink().exists()).toBe(true);
- });
-
- it('should render a time ago tooltip if note has createdAt property', () => {
- createComponent({
- note: {
- ...note,
- createdAt: '2019-07-26T15:02:20Z',
- },
- });
-
- expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
- });
-
- it('should trigger a scrollIntoView method', () => {
- createComponent({
- note,
- });
-
- expect(scrollIntoViewMock).toHaveBeenCalled();
- });
-
- it('should not render edit icon when user does not have a permission', () => {
- createComponent({
- note,
- });
-
- expect(findEditButton().exists()).toBe(false);
- });
-
- describe('when user has a permission to edit note', () => {
- it('should open an edit form on edit button click', () => {
- createComponent({
- note: {
- ...note,
- userPermissions: {
- adminNote: true,
- },
- },
- });
-
- findEditButton().trigger('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findReplyForm().exists()).toBe(true);
- expect(findNoteContent().exists()).toBe(false);
- });
- });
-
- describe('when edit form is rendered', () => {
- beforeEach(() => {
- createComponent(
- {
- note: {
- ...note,
- userPermissions: {
- adminNote: true,
- },
- },
- },
- { isEditing: true },
- );
- });
-
- it('should not render note content and should render reply form', () => {
- expect(findNoteContent().exists()).toBe(false);
- expect(findReplyForm().exists()).toBe(true);
- });
-
- it('hides the form on hideForm event', () => {
- findReplyForm().vm.$emit('cancelForm');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findReplyForm().exists()).toBe(false);
- expect(findNoteContent().exists()).toBe(true);
- });
- });
-
- it('calls a mutation on submitForm event and hides a form', () => {
- findReplyForm().vm.$emit('submitForm');
- expect(mutate).toHaveBeenCalled();
-
- return mutate()
- .then(() => {
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(findReplyForm().exists()).toBe(false);
- expect(findNoteContent().exists()).toBe(true);
- });
- });
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management_legacy/components/design_notes/design_reply_form_spec.js
deleted file mode 100644
index 088a71b64af..00000000000
--- a/spec/frontend/design_management_legacy/components/design_notes/design_reply_form_spec.js
+++ /dev/null
@@ -1,184 +0,0 @@
-import { mount } from '@vue/test-utils';
-import DesignReplyForm from '~/design_management_legacy/components/design_notes/design_reply_form.vue';
-
-const showModal = jest.fn();
-
-const GlModal = {
- template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
- methods: {
- show: showModal,
- },
-};
-
-describe('Design reply form component', () => {
- let wrapper;
-
- const findTextarea = () => wrapper.find('textarea');
- const findSubmitButton = () => wrapper.find({ ref: 'submitButton' });
- const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
- const findModal = () => wrapper.find({ ref: 'cancelCommentModal' });
-
- function createComponent(props = {}, mountOptions = {}) {
- wrapper = mount(DesignReplyForm, {
- propsData: {
- value: '',
- isSaving: false,
- ...props,
- },
- stubs: { GlModal },
- ...mountOptions,
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('textarea has focus after component mount', () => {
- // We need to attach to document, so that `document.activeElement` is properly set in jsdom
- createComponent({}, { attachToDocument: true });
-
- expect(findTextarea().element).toEqual(document.activeElement);
- });
-
- it('renders button text as "Comment" when creating a comment', () => {
- createComponent();
-
- expect(findSubmitButton().html()).toMatchSnapshot();
- });
-
- it('renders button text as "Save comment" when creating a comment', () => {
- createComponent({ isNewComment: false });
-
- expect(findSubmitButton().html()).toMatchSnapshot();
- });
-
- describe('when form has no text', () => {
- beforeEach(() => {
- createComponent({
- value: '',
- });
- });
-
- it('submit button is disabled', () => {
- expect(findSubmitButton().attributes().disabled).toBeTruthy();
- });
-
- it('does not emit submitForm event on textarea ctrl+enter keydown', () => {
- findTextarea().trigger('keydown.enter', {
- ctrlKey: true,
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeFalsy();
- });
- });
-
- it('does not emit submitForm event on textarea meta+enter keydown', () => {
- findTextarea().trigger('keydown.enter', {
- metaKey: true,
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeFalsy();
- });
- });
-
- it('emits cancelForm event on pressing escape button on textarea', () => {
- findTextarea().trigger('keyup.esc');
-
- expect(wrapper.emitted('cancelForm')).toBeTruthy();
- });
-
- it('emits cancelForm event on clicking Cancel button', () => {
- findCancelButton().vm.$emit('click');
-
- expect(wrapper.emitted('cancelForm')).toHaveLength(1);
- });
- });
-
- describe('when form has text', () => {
- beforeEach(() => {
- createComponent({
- value: 'test',
- });
- });
-
- it('submit button is enabled', () => {
- expect(findSubmitButton().attributes().disabled).toBeFalsy();
- });
-
- it('emits submitForm event on Comment button click', () => {
- findSubmitButton().vm.$emit('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeTruthy();
- });
- });
-
- it('emits submitForm event on textarea ctrl+enter keydown', () => {
- findTextarea().trigger('keydown.enter', {
- ctrlKey: true,
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeTruthy();
- });
- });
-
- it('emits submitForm event on textarea meta+enter keydown', () => {
- findTextarea().trigger('keydown.enter', {
- metaKey: true,
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('submitForm')).toBeTruthy();
- });
- });
-
- it('emits input event on changing textarea content', () => {
- findTextarea().setValue('test2');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('input')).toBeTruthy();
- });
- });
-
- it('emits cancelForm event on Escape key if text was not changed', () => {
- findTextarea().trigger('keyup.esc');
-
- expect(wrapper.emitted('cancelForm')).toBeTruthy();
- });
-
- it('opens confirmation modal on Escape key when text has changed', () => {
- wrapper.setProps({ value: 'test2' });
-
- return wrapper.vm.$nextTick().then(() => {
- findTextarea().trigger('keyup.esc');
- expect(showModal).toHaveBeenCalled();
- });
- });
-
- it('emits cancelForm event on Cancel button click if text was not changed', () => {
- findCancelButton().trigger('click');
-
- expect(wrapper.emitted('cancelForm')).toBeTruthy();
- });
-
- it('opens confirmation modal on Cancel button click when text has changed', () => {
- wrapper.setProps({ value: 'test2' });
-
- return wrapper.vm.$nextTick().then(() => {
- findCancelButton().trigger('click');
- expect(showModal).toHaveBeenCalled();
- });
- });
-
- it('emits cancelForm event on modal Ok button click', () => {
- findTextarea().trigger('keyup.esc');
- findModal().vm.$emit('ok');
-
- expect(wrapper.emitted('cancelForm')).toBeTruthy();
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/design_notes/toggle_replies_widget_spec.js b/spec/frontend/design_management_legacy/components/design_notes/toggle_replies_widget_spec.js
deleted file mode 100644
index acc7cbbca52..00000000000
--- a/spec/frontend/design_management_legacy/components/design_notes/toggle_replies_widget_spec.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlIcon, GlButton, GlLink } from '@gitlab/ui';
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import ToggleRepliesWidget from '~/design_management_legacy/components/design_notes/toggle_replies_widget.vue';
-import notes from '../../mock_data/notes';
-
-describe('Toggle replies widget component', () => {
- let wrapper;
-
- const findToggleWrapper = () => wrapper.find('[data-testid="toggle-comments-wrapper"]');
- const findIcon = () => wrapper.find(GlIcon);
- const findButton = () => wrapper.find(GlButton);
- const findAuthorLink = () => wrapper.find(GlLink);
- const findTimeAgo = () => wrapper.find(TimeAgoTooltip);
-
- function createComponent(props = {}) {
- wrapper = shallowMount(ToggleRepliesWidget, {
- propsData: {
- collapsed: true,
- replies: notes,
- ...props,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when replies are collapsed', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('should not have expanded class', () => {
- expect(findToggleWrapper().classes()).not.toContain('expanded');
- });
-
- it('should render chevron-right icon', () => {
- expect(findIcon().props('name')).toBe('chevron-right');
- });
-
- it('should have replies length on button', () => {
- expect(findButton().text()).toBe('2 replies');
- });
-
- it('should render a link to the last reply author', () => {
- expect(findAuthorLink().exists()).toBe(true);
- expect(findAuthorLink().text()).toBe(notes[1].author.name);
- expect(findAuthorLink().attributes('href')).toBe(notes[1].author.webUrl);
- });
-
- it('should render correct time ago tooltip', () => {
- expect(findTimeAgo().exists()).toBe(true);
- expect(findTimeAgo().props('time')).toBe(notes[1].createdAt);
- });
- });
-
- describe('when replies are expanded', () => {
- beforeEach(() => {
- createComponent({ collapsed: false });
- });
-
- it('should have expanded class', () => {
- expect(findToggleWrapper().classes()).toContain('expanded');
- });
-
- it('should render chevron-down icon', () => {
- expect(findIcon().props('name')).toBe('chevron-down');
- });
-
- it('should have Collapse replies text on button', () => {
- expect(findButton().text()).toBe('Collapse replies');
- });
-
- it('should not have a link to the last reply author', () => {
- expect(findAuthorLink().exists()).toBe(false);
- });
-
- it('should not render time ago tooltip', () => {
- expect(findTimeAgo().exists()).toBe(false);
- });
- });
-
- it('should emit toggle event on icon click', () => {
- createComponent();
- findIcon().vm.$emit('click', new MouseEvent('click'));
-
- expect(wrapper.emitted('toggle')).toHaveLength(1);
- });
-
- it('should emit toggle event on button click', () => {
- createComponent();
- findButton().vm.$emit('click', new MouseEvent('click'));
-
- expect(wrapper.emitted('toggle')).toHaveLength(1);
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/design_overlay_spec.js b/spec/frontend/design_management_legacy/components/design_overlay_spec.js
deleted file mode 100644
index c014f3479f4..00000000000
--- a/spec/frontend/design_management_legacy/components/design_overlay_spec.js
+++ /dev/null
@@ -1,410 +0,0 @@
-import { mount } from '@vue/test-utils';
-import DesignOverlay from '~/design_management_legacy/components/design_overlay.vue';
-import updateActiveDiscussion from '~/design_management_legacy/graphql/mutations/update_active_discussion.mutation.graphql';
-import notes from '../mock_data/notes';
-import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management_legacy/constants';
-
-const mutate = jest.fn(() => Promise.resolve());
-
-describe('Design overlay component', () => {
- let wrapper;
-
- const mockDimensions = { width: 100, height: 100 };
-
- const findOverlay = () => wrapper.find('.image-diff-overlay');
- const findAllNotes = () => wrapper.findAll('.js-image-badge');
- const findCommentBadge = () => wrapper.find('.comment-indicator');
- const findFirstBadge = () => findAllNotes().at(0);
- const findSecondBadge = () => findAllNotes().at(1);
-
- const clickAndDragBadge = (elem, fromPoint, toPoint) => {
- elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y });
- return wrapper.vm.$nextTick().then(() => {
- elem.trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y });
- return wrapper.vm.$nextTick();
- });
- };
-
- function createComponent(props = {}, data = {}) {
- wrapper = mount(DesignOverlay, {
- propsData: {
- dimensions: mockDimensions,
- position: {
- top: '0',
- left: '0',
- },
- resolvedDiscussionsExpanded: false,
- ...props,
- },
- data() {
- return {
- activeDiscussion: {
- id: null,
- source: null,
- },
- ...data,
- };
- },
- mocks: {
- $apollo: {
- mutate,
- },
- },
- });
- }
-
- it('should have correct inline style', () => {
- createComponent();
-
- expect(wrapper.find('.image-diff-overlay').attributes().style).toBe(
- 'width: 100px; height: 100px; top: 0px; left: 0px;',
- );
- });
-
- it('should emit `openCommentForm` when clicking on overlay', () => {
- createComponent();
- const newCoordinates = {
- x: 10,
- y: 10,
- };
-
- wrapper
- .find('.image-diff-overlay-add-comment')
- .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('openCommentForm')).toEqual([
- [{ x: newCoordinates.x, y: newCoordinates.y }],
- ]);
- });
- });
-
- describe('with notes', () => {
- it('should render only the first note', () => {
- createComponent({
- notes,
- });
- expect(findAllNotes()).toHaveLength(1);
- });
-
- describe('with resolved discussions toggle expanded', () => {
- beforeEach(() => {
- createComponent({
- notes,
- resolvedDiscussionsExpanded: true,
- });
- });
-
- it('should render all notes', () => {
- expect(findAllNotes()).toHaveLength(notes.length);
- });
-
- it('should have set the correct position for each note badge', () => {
- expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;');
- expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;');
- });
-
- it('should apply resolved class to the resolved note pin', () => {
- expect(findSecondBadge().classes()).toContain('resolved');
- });
-
- it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => {
- wrapper.setData({
- activeDiscussion: {
- id: notes[0].id,
- source: 'discussion',
- },
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findSecondBadge().classes()).toContain('inactive');
- });
- });
- });
-
- it('should recalculate badges positions on window resize', () => {
- createComponent({
- notes,
- dimensions: {
- width: 400,
- height: 400,
- },
- });
-
- expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;');
-
- wrapper.setProps({
- dimensions: {
- width: 200,
- height: 200,
- },
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;');
- });
- });
-
- it('should call an update active discussion mutation when clicking a note without moving it', () => {
- const note = notes[0];
- const { position } = note;
- const mutationVariables = {
- mutation: updateActiveDiscussion,
- variables: {
- id: note.id,
- source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
- },
- };
-
- findFirstBadge().trigger('mousedown', { clientX: position.x, clientY: position.y });
-
- return wrapper.vm.$nextTick().then(() => {
- findFirstBadge().trigger('mouseup', { clientX: position.x, clientY: position.y });
- expect(mutate).toHaveBeenCalledWith(mutationVariables);
- });
- });
- });
-
- describe('when moving notes', () => {
- it('should update badge style when note is being moved', () => {
- createComponent({
- notes,
- });
-
- const { position } = notes[0];
-
- return clickAndDragBadge(
- findFirstBadge(),
- { x: position.x, y: position.y },
- { x: 20, y: 20 },
- ).then(() => {
- expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px; cursor: move;');
- });
- });
-
- it('should emit `moveNote` event when note-moving action ends', () => {
- createComponent({ notes });
- const note = notes[0];
- const { position } = note;
- const newCoordinates = { x: 20, y: 20 };
-
- wrapper.setData({
- movingNoteNewPosition: {
- ...position,
- ...newCoordinates,
- },
- movingNoteStartPosition: {
- noteId: notes[0].id,
- discussionId: notes[0].discussion.id,
- ...position,
- },
- });
-
- const badge = findFirstBadge();
- return clickAndDragBadge(badge, { x: position.x, y: position.y }, newCoordinates)
- .then(() => {
- badge.trigger('mouseup');
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(wrapper.emitted('moveNote')).toEqual([
- [
- {
- noteId: notes[0].id,
- discussionId: notes[0].discussion.id,
- coordinates: newCoordinates,
- },
- ],
- ]);
- });
- });
-
- describe('without [adminNote] permission', () => {
- const mockNoteNotAuthorised = {
- ...notes[0],
- userPermissions: {
- adminNote: false,
- },
- };
-
- const mockNoteCoordinates = {
- x: mockNoteNotAuthorised.position.x,
- y: mockNoteNotAuthorised.position.y,
- };
-
- it('should be unable to move a note', () => {
- createComponent({
- dimensions: mockDimensions,
- notes: [mockNoteNotAuthorised],
- });
-
- const badge = findAllNotes().at(0);
- return clickAndDragBadge(badge, { ...mockNoteCoordinates }, { x: 20, y: 20 }).then(() => {
- // note position should not change after a click-and-drag attempt
- expect(findFirstBadge().attributes().style).toContain(
- `left: ${mockNoteCoordinates.x}px; top: ${mockNoteCoordinates.y}px;`,
- );
- });
- });
- });
- });
-
- describe('with a new form', () => {
- it('should render a new comment badge', () => {
- createComponent({
- currentCommentForm: {
- ...notes[0].position,
- },
- });
-
- expect(findCommentBadge().exists()).toBe(true);
- expect(findCommentBadge().attributes().style).toBe('left: 10px; top: 15px;');
- });
-
- describe('when moving the comment badge', () => {
- it('should update badge style to reflect new position', () => {
- const { position } = notes[0];
-
- createComponent({
- currentCommentForm: {
- ...position,
- },
- });
-
- return clickAndDragBadge(
- findCommentBadge(),
- { x: position.x, y: position.y },
- { x: 20, y: 20 },
- ).then(() => {
- expect(findCommentBadge().attributes().style).toBe(
- 'left: 20px; top: 20px; cursor: move;',
- );
- });
- });
-
- it('should update badge style when note-moving action ends', () => {
- const { position } = notes[0];
- createComponent({
- currentCommentForm: {
- ...position,
- },
- });
-
- const commentBadge = findCommentBadge();
- const toPoint = { x: 20, y: 20 };
-
- return clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint)
- .then(() => {
- commentBadge.trigger('mouseup');
- // simulates the currentCommentForm being updated in index.vue component, and
- // propagated back down to this prop
- wrapper.setProps({
- currentCommentForm: { height: position.height, width: position.width, ...toPoint },
- });
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(commentBadge.attributes().style).toBe('left: 20px; top: 20px;');
- });
- });
-
- it.each`
- element | getElementFunc | event
- ${'overlay'} | ${findOverlay} | ${'mouseleave'}
- ${'comment badge'} | ${findCommentBadge} | ${'mouseup'}
- `(
- 'should emit `openCommentForm` event when $event fired on $element element',
- ({ getElementFunc, event }) => {
- createComponent({
- notes,
- currentCommentForm: {
- ...notes[0].position,
- },
- });
-
- const newCoordinates = { x: 20, y: 20 };
- wrapper.setData({
- movingNoteStartPosition: {
- ...notes[0].position,
- },
- movingNoteNewPosition: {
- ...notes[0].position,
- ...newCoordinates,
- },
- });
-
- getElementFunc().trigger(event);
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]);
- });
- },
- );
- });
- });
-
- describe('getMovingNotePositionDelta', () => {
- it('should calculate delta correctly from state', () => {
- createComponent();
-
- wrapper.setData({
- movingNoteStartPosition: {
- clientX: 10,
- clientY: 20,
- },
- });
-
- const mockMouseEvent = {
- clientX: 30,
- clientY: 10,
- };
-
- expect(wrapper.vm.getMovingNotePositionDelta(mockMouseEvent)).toEqual({
- deltaX: 20,
- deltaY: -10,
- });
- });
- });
-
- describe('isPositionInOverlay', () => {
- createComponent({ dimensions: mockDimensions });
-
- it.each`
- test | coordinates | expectedResult
- ${'within overlay bounds'} | ${{ x: 50, y: 50 }} | ${true}
- ${'outside overlay bounds'} | ${{ x: 101, y: 101 }} | ${false}
- `('returns [$expectedResult] when position is $test', ({ coordinates, expectedResult }) => {
- const position = { ...mockDimensions, ...coordinates };
-
- expect(wrapper.vm.isPositionInOverlay(position)).toBe(expectedResult);
- });
- });
-
- describe('getNoteRelativePosition', () => {
- it('calculates position correctly', () => {
- createComponent({ dimensions: mockDimensions });
- const position = { x: 50, y: 50, width: 200, height: 200 };
-
- expect(wrapper.vm.getNoteRelativePosition(position)).toEqual({ left: 25, top: 25 });
- });
- });
-
- describe('canMoveNote', () => {
- it.each`
- adminNotePermission | canMoveNoteResult
- ${true} | ${true}
- ${false} | ${false}
- ${undefined} | ${false}
- `(
- 'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]',
- ({ adminNotePermission, canMoveNoteResult }) => {
- createComponent();
-
- const note = {
- userPermissions: {
- adminNote: adminNotePermission,
- },
- };
- expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult);
- },
- );
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/design_presentation_spec.js b/spec/frontend/design_management_legacy/components/design_presentation_spec.js
deleted file mode 100644
index ceff86b0549..00000000000
--- a/spec/frontend/design_management_legacy/components/design_presentation_spec.js
+++ /dev/null
@@ -1,553 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import DesignPresentation from '~/design_management_legacy/components/design_presentation.vue';
-import DesignOverlay from '~/design_management_legacy/components/design_overlay.vue';
-
-const mockOverlayData = {
- overlayDimensions: {
- width: 100,
- height: 100,
- },
- overlayPosition: {
- top: '0',
- left: '0',
- },
-};
-
-describe('Design management design presentation component', () => {
- let wrapper;
-
- function createComponent(
- {
- image,
- imageName,
- discussions = [],
- isAnnotating = false,
- resolvedDiscussionsExpanded = false,
- } = {},
- data = {},
- stubs = {},
- ) {
- wrapper = shallowMount(DesignPresentation, {
- propsData: {
- image,
- imageName,
- discussions,
- isAnnotating,
- resolvedDiscussionsExpanded,
- },
- stubs,
- });
-
- wrapper.setData(data);
- wrapper.element.scrollTo = jest.fn();
- }
-
- const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment');
-
- /**
- * Spy on $refs and mock given values
- * @param {Object} viewportDimensions {width, height}
- * @param {Object} childDimensions {width, height}
- * @param {Float} scrollTopPerc 0 < x < 1
- * @param {Float} scrollLeftPerc 0 < x < 1
- */
- function mockRefDimensions(
- ref,
- viewportDimensions,
- childDimensions,
- scrollTopPerc,
- scrollLeftPerc,
- ) {
- jest.spyOn(ref, 'scrollWidth', 'get').mockReturnValue(childDimensions.width);
- jest.spyOn(ref, 'scrollHeight', 'get').mockReturnValue(childDimensions.height);
- jest.spyOn(ref, 'offsetWidth', 'get').mockReturnValue(viewportDimensions.width);
- jest.spyOn(ref, 'offsetHeight', 'get').mockReturnValue(viewportDimensions.height);
- jest
- .spyOn(ref, 'scrollLeft', 'get')
- .mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc);
- jest
- .spyOn(ref, 'scrollTop', 'get')
- .mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc);
- }
-
- function clickDragExplore(startCoords, endCoords, { useTouchEvents, mouseup } = {}) {
- const event = useTouchEvents
- ? {
- mousedown: 'touchstart',
- mousemove: 'touchmove',
- mouseup: 'touchend',
- }
- : {
- mousedown: 'mousedown',
- mousemove: 'mousemove',
- mouseup: 'mouseup',
- };
-
- const addCommentOverlay = findOverlayCommentButton();
-
- // triggering mouse events on this element best simulates
- // reality, as it is the lowest-level node that needs to
- // respond to mouse events
- addCommentOverlay.trigger(event.mousedown, {
- clientX: startCoords.clientX,
- clientY: startCoords.clientY,
- });
- return wrapper.vm
- .$nextTick()
- .then(() => {
- addCommentOverlay.trigger(event.mousemove, {
- clientX: endCoords.clientX,
- clientY: endCoords.clientY,
- });
-
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- if (mouseup) {
- addCommentOverlay.trigger(event.mouseup);
- return wrapper.vm.$nextTick();
- }
-
- return undefined;
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders image and overlay when image provided', () => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- },
- mockOverlayData,
- );
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('renders empty state when no image provided', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('openCommentForm event emits correct data', () => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- },
- mockOverlayData,
- );
-
- wrapper.vm.openCommentForm({ x: 1, y: 1 });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('openCommentForm')).toEqual([
- [{ ...mockOverlayData.overlayDimensions, x: 1, y: 1 }],
- ]);
- });
- });
-
- describe('currentCommentForm', () => {
- it('is null when isAnnotating is false', () => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- },
- mockOverlayData,
- );
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.currentCommentForm).toBeNull();
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('is null when isAnnotating is true but annotation position is falsey', () => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- isAnnotating: true,
- },
- mockOverlayData,
- );
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.currentCommentForm).toBeNull();
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('is equal to current annotation position when isAnnotating is true', () => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- isAnnotating: true,
- },
- {
- ...mockOverlayData,
- currentAnnotationPosition: {
- x: 1,
- y: 1,
- width: 100,
- height: 100,
- },
- },
- );
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.currentCommentForm).toEqual({
- x: 1,
- y: 1,
- width: 100,
- height: 100,
- });
- expect(wrapper.element).toMatchSnapshot();
- });
- });
- });
-
- describe('setOverlayPosition', () => {
- beforeEach(() => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- },
- mockOverlayData,
- );
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- it('sets overlay position correctly when overlay is smaller than viewport', () => {
- jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
- jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
-
- wrapper.vm.setOverlayPosition();
- expect(wrapper.vm.overlayPosition).toEqual({
- left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
- top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
- });
- });
-
- it('sets overlay position correctly when overlay width is larger than viewports', () => {
- jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(50);
- jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
-
- wrapper.vm.setOverlayPosition();
- expect(wrapper.vm.overlayPosition).toEqual({
- left: '0',
- top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
- });
- });
-
- it('sets overlay position correctly when overlay height is larger than viewports', () => {
- jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
- jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(50);
-
- wrapper.vm.setOverlayPosition();
- expect(wrapper.vm.overlayPosition).toEqual({
- left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
- top: '0',
- });
- });
- });
-
- describe('getViewportCenter', () => {
- beforeEach(() => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- },
- mockOverlayData,
- );
- });
-
- it('calculate center correctly with no scroll', () => {
- mockRefDimensions(
- wrapper.vm.$refs.presentationViewport,
- { width: 10, height: 10 },
- { width: 20, height: 20 },
- 0,
- 0,
- );
-
- expect(wrapper.vm.getViewportCenter()).toEqual({
- x: 5,
- y: 5,
- });
- });
-
- it('calculate center correctly with some scroll', () => {
- mockRefDimensions(
- wrapper.vm.$refs.presentationViewport,
- { width: 10, height: 10 },
- { width: 20, height: 20 },
- 0.5,
- 0.5,
- );
-
- expect(wrapper.vm.getViewportCenter()).toEqual({
- x: 10,
- y: 10,
- });
- });
-
- it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => {
- mockRefDimensions(
- wrapper.vm.$refs.presentationViewport,
- { width: 20, height: 20 },
- { width: 20, height: 20 },
- 0.5,
- 0.5,
- );
-
- expect(wrapper.vm.getViewportCenter()).toEqual({
- x: 10,
- y: 10,
- });
- });
- });
-
- describe('scaleZoomFocalPoint', () => {
- it('scales focal point correctly when zooming in', () => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- },
- {
- ...mockOverlayData,
- zoomFocalPoint: {
- x: 5,
- y: 5,
- width: 50,
- height: 50,
- },
- },
- );
-
- wrapper.vm.scaleZoomFocalPoint();
- expect(wrapper.vm.zoomFocalPoint).toEqual({
- x: 10,
- y: 10,
- width: 100,
- height: 100,
- });
- });
-
- it('scales focal point correctly when zooming out', () => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- },
- {
- ...mockOverlayData,
- zoomFocalPoint: {
- x: 10,
- y: 10,
- width: 200,
- height: 200,
- },
- },
- );
-
- wrapper.vm.scaleZoomFocalPoint();
- expect(wrapper.vm.zoomFocalPoint).toEqual({
- x: 5,
- y: 5,
- width: 100,
- height: 100,
- });
- });
- });
-
- describe('onImageResize', () => {
- it('sets zoom focal point on initial load', () => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- },
- mockOverlayData,
- );
-
- wrapper.setMethods({
- shiftZoomFocalPoint: jest.fn(),
- scaleZoomFocalPoint: jest.fn(),
- scrollToFocalPoint: jest.fn(),
- });
-
- wrapper.vm.onImageResize({ width: 10, height: 10 });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shiftZoomFocalPoint).toHaveBeenCalled();
- expect(wrapper.vm.initialLoad).toBe(false);
- });
- });
-
- it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', () => {
- wrapper.vm.onImageResize({ width: 10, height: 10 });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled();
- expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled();
- });
- });
- });
-
- describe('onPresentationMousedown', () => {
- it.each`
- scenario | width | height
- ${'width overflows'} | ${101} | ${100}
- ${'height overflows'} | ${100} | ${101}
- ${'width and height overflows'} | ${200} | ${200}
- `('sets lastDragPosition when design $scenario', ({ width, height }) => {
- createComponent();
- mockRefDimensions(
- wrapper.vm.$refs.presentationViewport,
- { width: 100, height: 100 },
- { width, height },
- );
-
- const newLastDragPosition = { x: 2, y: 2 };
- wrapper.vm.onPresentationMousedown({
- clientX: newLastDragPosition.x,
- clientY: newLastDragPosition.y,
- });
-
- expect(wrapper.vm.lastDragPosition).toStrictEqual(newLastDragPosition);
- });
-
- it('does not set lastDragPosition if design does not overflow', () => {
- const lastDragPosition = { x: 1, y: 1 };
-
- createComponent({}, { lastDragPosition });
- mockRefDimensions(
- wrapper.vm.$refs.presentationViewport,
- { width: 100, height: 100 },
- { width: 50, height: 50 },
- );
-
- wrapper.vm.onPresentationMousedown({ clientX: 2, clientY: 2 });
-
- // check lastDragPosition is unchanged
- expect(wrapper.vm.lastDragPosition).toStrictEqual(lastDragPosition);
- });
- });
-
- describe('getAnnotationPositon', () => {
- it.each`
- coordinates | overlayDimensions | position
- ${{ x: 100, y: 100 }} | ${{ width: 50, height: 50 }} | ${{ x: 100, y: 100, width: 50, height: 50 }}
- ${{ x: 100.2, y: 100.5 }} | ${{ width: 50.6, height: 50.0 }} | ${{ x: 100, y: 101, width: 51, height: 50 }}
- `('returns correct annotation position', ({ coordinates, overlayDimensions, position }) => {
- createComponent(undefined, {
- overlayDimensions: {
- width: overlayDimensions.width,
- height: overlayDimensions.height,
- },
- });
-
- expect(wrapper.vm.getAnnotationPositon(coordinates)).toStrictEqual(position);
- });
- });
-
- describe('when design is overflowing', () => {
- beforeEach(() => {
- createComponent(
- {
- image: 'test.jpg',
- imageName: 'test',
- },
- mockOverlayData,
- {
- 'design-overlay': DesignOverlay,
- },
- );
-
- // mock a design that overflows
- mockRefDimensions(
- wrapper.vm.$refs.presentationViewport,
- { width: 10, height: 10 },
- { width: 20, height: 20 },
- 0,
- 0,
- );
- });
-
- it('opens a comment form if design was not dragged', () => {
- const addCommentOverlay = findOverlayCommentButton();
- const startCoords = {
- clientX: 1,
- clientY: 1,
- };
-
- addCommentOverlay.trigger('mousedown', {
- clientX: startCoords.clientX,
- clientY: startCoords.clientY,
- });
-
- return wrapper.vm
- .$nextTick()
- .then(() => {
- addCommentOverlay.trigger('mouseup');
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(wrapper.emitted('openCommentForm')).toBeDefined();
- });
- });
-
- describe('when clicking and dragging', () => {
- it.each`
- description | useTouchEvents
- ${'with touch events'} | ${true}
- ${'without touch events'} | ${false}
- `('calls scrollTo with correct arguments $description', ({ useTouchEvents }) => {
- return clickDragExplore(
- { clientX: 0, clientY: 0 },
- { clientX: 10, clientY: 10 },
- { useTouchEvents },
- ).then(() => {
- expect(wrapper.element.scrollTo).toHaveBeenCalledTimes(1);
- expect(wrapper.element.scrollTo).toHaveBeenCalledWith(-10, -10);
- });
- });
-
- it('does not open a comment form when drag position exceeds buffer', () => {
- return clickDragExplore(
- { clientX: 0, clientY: 0 },
- { clientX: 10, clientY: 10 },
- { mouseup: true },
- ).then(() => {
- expect(wrapper.emitted('openCommentForm')).toBeFalsy();
- });
- });
-
- it('opens a comment form when drag position is within buffer', () => {
- return clickDragExplore(
- { clientX: 0, clientY: 0 },
- { clientX: 1, clientY: 0 },
- { mouseup: true },
- ).then(() => {
- expect(wrapper.emitted('openCommentForm')).toBeDefined();
- });
- });
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/design_scaler_spec.js b/spec/frontend/design_management_legacy/components/design_scaler_spec.js
deleted file mode 100644
index 30ef5ab159b..00000000000
--- a/spec/frontend/design_management_legacy/components/design_scaler_spec.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import DesignScaler from '~/design_management_legacy/components/design_scaler.vue';
-
-describe('Design management design scaler component', () => {
- let wrapper;
-
- function createComponent(propsData, data = {}) {
- wrapper = shallowMount(DesignScaler, {
- propsData,
- });
- wrapper.setData(data);
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const getButton = type => {
- const buttonTypeOrder = ['minus', 'reset', 'plus'];
- const buttons = wrapper.findAll('button');
- return buttons.at(buttonTypeOrder.indexOf(type));
- };
-
- it('emits @scale event when "plus" button clicked', () => {
- createComponent();
-
- getButton('plus').trigger('click');
- expect(wrapper.emitted('scale')).toEqual([[1.2]]);
- });
-
- it('emits @scale event when "reset" button clicked (scale > 1)', () => {
- createComponent({}, { scale: 1.6 });
- return wrapper.vm.$nextTick().then(() => {
- getButton('reset').trigger('click');
- expect(wrapper.emitted('scale')).toEqual([[1]]);
- });
- });
-
- it('emits @scale event when "minus" button clicked (scale > 1)', () => {
- createComponent({}, { scale: 1.6 });
-
- return wrapper.vm.$nextTick().then(() => {
- getButton('minus').trigger('click');
- expect(wrapper.emitted('scale')).toEqual([[1.4]]);
- });
- });
-
- it('minus and reset buttons are disabled when scale === 1', () => {
- createComponent();
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('minus and reset buttons are enabled when scale > 1', () => {
- createComponent({}, { scale: 1.2 });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('plus button is disabled when scale === 2', () => {
- createComponent({}, { scale: 2 });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/design_sidebar_spec.js b/spec/frontend/design_management_legacy/components/design_sidebar_spec.js
deleted file mode 100644
index fc0f618c359..00000000000
--- a/spec/frontend/design_management_legacy/components/design_sidebar_spec.js
+++ /dev/null
@@ -1,236 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlCollapse, GlPopover } from '@gitlab/ui';
-import Cookies from 'js-cookie';
-import DesignSidebar from '~/design_management_legacy/components/design_sidebar.vue';
-import Participants from '~/sidebar/components/participants/participants.vue';
-import DesignDiscussion from '~/design_management_legacy/components/design_notes/design_discussion.vue';
-import design from '../mock_data/design';
-import updateActiveDiscussionMutation from '~/design_management_legacy/graphql/mutations/update_active_discussion.mutation.graphql';
-
-const updateActiveDiscussionMutationVariables = {
- mutation: updateActiveDiscussionMutation,
- variables: {
- id: design.discussions.nodes[0].notes.nodes[0].id,
- source: 'discussion',
- },
-};
-
-const $route = {
- params: {
- id: '1',
- },
-};
-
-const cookieKey = 'hide_design_resolved_comments_popover';
-
-const mutate = jest.fn().mockResolvedValue();
-
-describe('Design management design sidebar component', () => {
- let wrapper;
-
- const findDiscussions = () => wrapper.findAll(DesignDiscussion);
- const findFirstDiscussion = () => findDiscussions().at(0);
- const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]');
- const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]');
- const findParticipants = () => wrapper.find(Participants);
- const findCollapsible = () => wrapper.find(GlCollapse);
- const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]');
- const findPopover = () => wrapper.find(GlPopover);
- const findNewDiscussionDisclaimer = () =>
- wrapper.find('[data-testid="new-discussion-disclaimer"]');
-
- function createComponent(props = {}) {
- wrapper = shallowMount(DesignSidebar, {
- propsData: {
- design,
- resolvedDiscussionsExpanded: false,
- markdownPreviewPath: '',
- ...props,
- },
- mocks: {
- $route,
- $apollo: {
- mutate,
- },
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders participants', () => {
- createComponent();
-
- expect(findParticipants().exists()).toBe(true);
- });
-
- it('passes the correct amount of participants to the Participants component', () => {
- createComponent();
-
- expect(findParticipants().props('participants')).toHaveLength(1);
- });
-
- describe('when has no discussions', () => {
- beforeEach(() => {
- createComponent({
- design: {
- ...design,
- discussions: {
- nodes: [],
- },
- },
- });
- });
-
- it('does not render discussions', () => {
- expect(findDiscussions().exists()).toBe(false);
- });
-
- it('renders a message about possibility to create a new discussion', () => {
- expect(findNewDiscussionDisclaimer().exists()).toBe(true);
- });
- });
-
- describe('when has discussions', () => {
- beforeEach(() => {
- Cookies.set(cookieKey, true);
- createComponent();
- });
-
- it('renders correct amount of unresolved discussions', () => {
- expect(findUnresolvedDiscussions()).toHaveLength(1);
- });
-
- it('renders correct amount of resolved discussions', () => {
- expect(findResolvedDiscussions()).toHaveLength(1);
- });
-
- it('has resolved comments collapsible collapsed', () => {
- expect(findCollapsible().attributes('visible')).toBeUndefined();
- });
-
- it('emits toggleResolveComments event on resolve comments button click', () => {
- findToggleResolvedCommentsButton().vm.$emit('click');
- expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1);
- });
-
- it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', () => {
- expect(findCollapsible().attributes('visible')).toBeUndefined();
- wrapper.setProps({
- resolvedDiscussionsExpanded: true,
- });
- return wrapper.vm.$nextTick().then(() => {
- expect(findCollapsible().attributes('visible')).toBe('true');
- });
- });
-
- it('does not popover about resolved comments', () => {
- expect(findPopover().exists()).toBe(false);
- });
-
- it('sends a mutation to set an active discussion when clicking on a discussion', () => {
- findFirstDiscussion().trigger('click');
-
- expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables);
- });
-
- it('sends a mutation to reset an active discussion when clicking outside of discussion', () => {
- wrapper.trigger('click');
-
- expect(mutate).toHaveBeenCalledWith({
- ...updateActiveDiscussionMutationVariables,
- variables: { id: undefined, source: 'discussion' },
- });
- });
-
- it('emits correct event on discussion create note error', () => {
- findFirstDiscussion().vm.$emit('createNoteError', 'payload');
- expect(wrapper.emitted('onDesignDiscussionError')).toEqual([['payload']]);
- });
-
- it('emits correct event on discussion update note error', () => {
- findFirstDiscussion().vm.$emit('updateNoteError', 'payload');
- expect(wrapper.emitted('updateNoteError')).toEqual([['payload']]);
- });
-
- it('emits correct event on discussion resolve error', () => {
- findFirstDiscussion().vm.$emit('resolveDiscussionError', 'payload');
- expect(wrapper.emitted('resolveDiscussionError')).toEqual([['payload']]);
- });
-
- it('changes prop correctly on opening discussion form', () => {
- findFirstDiscussion().vm.$emit('openForm', 'some-id');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findFirstDiscussion().props('discussionWithOpenForm')).toBe('some-id');
- });
- });
- });
-
- describe('when all discussions are resolved', () => {
- beforeEach(() => {
- createComponent({
- design: {
- ...design,
- discussions: {
- nodes: [
- {
- id: 'discussion-id',
- replyId: 'discussion-reply-id',
- resolved: true,
- notes: {
- nodes: [
- {
- id: 'note-id',
- body: '123',
- author: {
- name: 'Administrator',
- username: 'root',
- webUrl: 'link-to-author',
- avatarUrl: 'link-to-avatar',
- },
- },
- ],
- },
- },
- ],
- },
- },
- });
- });
-
- it('renders a message about possibility to create a new discussion', () => {
- expect(findNewDiscussionDisclaimer().exists()).toBe(true);
- });
-
- it('does not render unresolved discussions', () => {
- expect(findUnresolvedDiscussions()).toHaveLength(0);
- });
- });
-
- describe('when showing resolved discussions for the first time', () => {
- beforeEach(() => {
- Cookies.set(cookieKey, false);
- createComponent();
- });
-
- it('renders a popover if we show resolved comments collapsible for the first time', () => {
- expect(findPopover().exists()).toBe(true);
- });
-
- it('dismisses a popover on the outside click', () => {
- wrapper.trigger('click');
- return wrapper.vm.$nextTick(() => {
- expect(findPopover().exists()).toBe(false);
- });
- });
-
- it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => {
- jest.spyOn(Cookies, 'set');
- wrapper.trigger('click');
- expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 });
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/image_spec.js b/spec/frontend/design_management_legacy/components/image_spec.js
deleted file mode 100644
index 265c91abb4e..00000000000
--- a/spec/frontend/design_management_legacy/components/image_spec.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlIcon } from '@gitlab/ui';
-import DesignImage from '~/design_management_legacy/components/image.vue';
-
-describe('Design management large image component', () => {
- let wrapper;
-
- function createComponent(propsData, data = {}) {
- wrapper = shallowMount(DesignImage, {
- propsData,
- });
- wrapper.setData(data);
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders loading state', () => {
- createComponent({
- isLoading: true,
- });
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('renders image', () => {
- createComponent({
- isLoading: false,
- image: 'test.jpg',
- name: 'test',
- });
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('sets correct classes and styles if imageStyle is set', () => {
- createComponent(
- {
- isLoading: false,
- image: 'test.jpg',
- name: 'test',
- },
- {
- imageStyle: {
- width: '100px',
- height: '100px',
- },
- },
- );
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('renders media broken icon on error', () => {
- createComponent({
- isLoading: false,
- image: 'test.jpg',
- name: 'test',
- });
-
- const image = wrapper.find('img');
- image.trigger('error');
- return wrapper.vm.$nextTick().then(() => {
- expect(image.isVisible()).toBe(false);
- expect(wrapper.find(GlIcon).element).toMatchSnapshot();
- });
- });
-
- describe('zoom', () => {
- const baseImageWidth = 100;
- const baseImageHeight = 100;
-
- beforeEach(() => {
- createComponent(
- {
- isLoading: false,
- image: 'test.jpg',
- name: 'test',
- },
- {
- imageStyle: {
- width: `${baseImageWidth}px`,
- height: `${baseImageHeight}px`,
- },
- baseImageSize: {
- width: baseImageWidth,
- height: baseImageHeight,
- },
- },
- );
-
- jest.spyOn(wrapper.vm.$refs.contentImg, 'offsetWidth', 'get').mockReturnValue(baseImageWidth);
- jest
- .spyOn(wrapper.vm.$refs.contentImg, 'offsetHeight', 'get')
- .mockReturnValue(baseImageHeight);
- });
-
- it('emits @resize event on zoom', () => {
- const zoomAmount = 2;
- wrapper.vm.zoom(zoomAmount);
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('resize')).toEqual([
- [{ width: baseImageWidth * zoomAmount, height: baseImageHeight * zoomAmount }],
- ]);
- });
- });
-
- it('emits @resize event with base image size when scale=1', () => {
- wrapper.vm.zoom(1);
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('resize')).toEqual([
- [{ width: baseImageWidth, height: baseImageHeight }],
- ]);
- });
- });
-
- it('sets image style when zoomed', () => {
- const zoomAmount = 2;
- wrapper.vm.zoom(zoomAmount);
- expect(wrapper.vm.imageStyle).toEqual({
- width: `${baseImageWidth * zoomAmount}px`,
- height: `${baseImageHeight * zoomAmount}px`,
- });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management_legacy/components/list/__snapshots__/item_spec.js.snap
deleted file mode 100644
index 168b9424006..00000000000
--- a/spec/frontend/design_management_legacy/components/list/__snapshots__/item_spec.js.snap
+++ /dev/null
@@ -1,149 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management list item component when item appears in view after image is loaded renders media broken icon when image onerror triggered 1`] = `
-<gl-icon-stub
- class="text-secondary"
- name="media-broken"
- size="32"
-/>
-`;
-
-exports[`Design management list item component with notes renders item with multiple comments 1`] = `
-<router-link-stub
- class="card cursor-pointer text-plain js-design-list-item design-list-item"
- to="[object Object]"
->
- <div
- class="card-body p-0 d-flex-center overflow-hidden position-relative"
- >
- <!---->
-
- <gl-intersection-observer-stub>
- <!---->
-
- <img
- alt="test"
- class="block mx-auto mw-100 mh-100 design-img"
- data-qa-selector="design_image"
- src=""
- />
- </gl-intersection-observer-stub>
- </div>
-
- <div
- class="card-footer d-flex w-100"
- >
- <div
- class="d-flex flex-column str-truncated-100"
- >
- <span
- class="bold str-truncated-100"
- data-qa-selector="design_file_name"
- >
- test
- </span>
-
- <span
- class="str-truncated-100"
- >
-
- Updated
- <timeago-stub
- cssclass=""
- time="01-01-2019"
- tooltipplacement="bottom"
- />
- </span>
- </div>
-
- <div
- class="ml-auto d-flex align-items-center text-secondary"
- >
- <icon-stub
- class="ml-1"
- name="comments"
- size="16"
- />
-
- <span
- aria-label="2 comments"
- class="ml-1"
- >
-
- 2
-
- </span>
- </div>
- </div>
-</router-link-stub>
-`;
-
-exports[`Design management list item component with notes renders item with single comment 1`] = `
-<router-link-stub
- class="card cursor-pointer text-plain js-design-list-item design-list-item"
- to="[object Object]"
->
- <div
- class="card-body p-0 d-flex-center overflow-hidden position-relative"
- >
- <!---->
-
- <gl-intersection-observer-stub>
- <!---->
-
- <img
- alt="test"
- class="block mx-auto mw-100 mh-100 design-img"
- data-qa-selector="design_image"
- src=""
- />
- </gl-intersection-observer-stub>
- </div>
-
- <div
- class="card-footer d-flex w-100"
- >
- <div
- class="d-flex flex-column str-truncated-100"
- >
- <span
- class="bold str-truncated-100"
- data-qa-selector="design_file_name"
- >
- test
- </span>
-
- <span
- class="str-truncated-100"
- >
-
- Updated
- <timeago-stub
- cssclass=""
- time="01-01-2019"
- tooltipplacement="bottom"
- />
- </span>
- </div>
-
- <div
- class="ml-auto d-flex align-items-center text-secondary"
- >
- <icon-stub
- class="ml-1"
- name="comments"
- size="16"
- />
-
- <span
- aria-label="1 comment"
- class="ml-1"
- >
-
- 1
-
- </span>
- </div>
- </div>
-</router-link-stub>
-`;
diff --git a/spec/frontend/design_management_legacy/components/list/item_spec.js b/spec/frontend/design_management_legacy/components/list/item_spec.js
deleted file mode 100644
index e9bb0fc3f29..00000000000
--- a/spec/frontend/design_management_legacy/components/list/item_spec.js
+++ /dev/null
@@ -1,169 +0,0 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
-import VueRouter from 'vue-router';
-import Icon from '~/vue_shared/components/icon.vue';
-import Item from '~/design_management_legacy/components/list/item.vue';
-
-const localVue = createLocalVue();
-localVue.use(VueRouter);
-const router = new VueRouter();
-
-// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent
-const DESIGN_VERSION_EVENT = {
- CREATION: 'CREATION',
- DELETION: 'DELETION',
- MODIFICATION: 'MODIFICATION',
- NO_CHANGE: 'NONE',
-};
-
-describe('Design management list item component', () => {
- let wrapper;
-
- const findDesignEvent = () => wrapper.find('[data-testid="designEvent"]');
- const findEventIcon = () => findDesignEvent().find(Icon);
- const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
-
- function createComponent({
- notesCount = 0,
- event = DESIGN_VERSION_EVENT.NO_CHANGE,
- isUploading = false,
- imageLoading = false,
- } = {}) {
- wrapper = shallowMount(Item, {
- localVue,
- router,
- propsData: {
- id: 1,
- filename: 'test',
- image: 'http://via.placeholder.com/300',
- isUploading,
- event,
- notesCount,
- updatedAt: '01-01-2019',
- },
- data() {
- return {
- imageLoading,
- };
- },
- stubs: ['router-link'],
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when item is not in view', () => {
- it('image is not rendered', () => {
- createComponent();
-
- const image = wrapper.find('img');
- expect(image.attributes('src')).toBe('');
- });
- });
-
- describe('when item appears in view', () => {
- let image;
- let glIntersectionObserver;
-
- beforeEach(() => {
- createComponent();
- image = wrapper.find('img');
- glIntersectionObserver = wrapper.find(GlIntersectionObserver);
-
- glIntersectionObserver.vm.$emit('appear');
- return wrapper.vm.$nextTick();
- });
-
- describe('before image is loaded', () => {
- it('renders loading spinner', () => {
- expect(wrapper.find(GlLoadingIcon)).toExist();
- });
- });
-
- describe('after image is loaded', () => {
- beforeEach(() => {
- image.trigger('load');
- return wrapper.vm.$nextTick();
- });
-
- it('renders an image', () => {
- expect(image.attributes('src')).toBe('http://via.placeholder.com/300');
- expect(image.isVisible()).toBe(true);
- });
-
- it('renders media broken icon when image onerror triggered', () => {
- image.trigger('error');
- return wrapper.vm.$nextTick().then(() => {
- expect(image.isVisible()).toBe(false);
- expect(wrapper.find(GlIcon).element).toMatchSnapshot();
- });
- });
-
- describe('when imageV432x230 and image provided', () => {
- it('renders imageV432x230 image', () => {
- const mockSrc = 'mock-imageV432x230-url';
- wrapper.setProps({ imageV432x230: mockSrc });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(image.attributes('src')).toBe(mockSrc);
- });
- });
- });
-
- describe('when image disappears from view and then reappears', () => {
- beforeEach(() => {
- glIntersectionObserver.vm.$emit('appear');
- return wrapper.vm.$nextTick();
- });
-
- it('renders an image', () => {
- expect(image.isVisible()).toBe(true);
- });
- });
- });
- });
-
- describe('with notes', () => {
- it('renders item with single comment', () => {
- createComponent({ notesCount: 1 });
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('renders item with multiple comments', () => {
- createComponent({ notesCount: 2 });
-
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('renders loading spinner when isUploading is true', () => {
- createComponent({ isUploading: true });
-
- expect(findLoadingIcon().exists()).toBe(true);
- });
-
- it('renders item with no status icon for none event', () => {
- createComponent();
-
- expect(findDesignEvent().exists()).toBe(false);
- });
-
- describe('with associated event', () => {
- it.each`
- event | icon | className
- ${DESIGN_VERSION_EVENT.MODIFICATION} | ${'file-modified-solid'} | ${'text-primary-500'}
- ${DESIGN_VERSION_EVENT.DELETION} | ${'file-deletion-solid'} | ${'text-danger-500'}
- ${DESIGN_VERSION_EVENT.CREATION} | ${'file-addition-solid'} | ${'text-success-500'}
- `('renders item with correct status icon for $event event', ({ event, icon, className }) => {
- createComponent({ event });
- const eventIcon = findEventIcon();
-
- expect(eventIcon.exists()).toBe(true);
- expect(eventIcon.props('name')).toBe(icon);
- expect(eventIcon.classes()).toContain(className);
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/index_spec.js.snap
deleted file mode 100644
index e55cff8de3d..00000000000
--- a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/index_spec.js.snap
+++ /dev/null
@@ -1,61 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management toolbar component renders design and updated data 1`] = `
-<header
- class="d-flex p-2 bg-white align-items-center js-design-header"
->
- <a
- aria-label="Go back to designs"
- class="mr-3 text-plain d-flex justify-content-center align-items-center"
- >
- <icon-stub
- name="close"
- size="18"
- />
- </a>
-
- <div
- class="overflow-hidden d-flex align-items-center"
- >
- <h2
- class="m-0 str-truncated-100 gl-font-base"
- >
- test.jpg
- </h2>
-
- <small
- class="text-secondary"
- >
- Updated 1 hour ago by Test Name
- </small>
- </div>
-
- <pagination-stub
- class="ml-auto flex-shrink-0"
- id="1"
- />
-
- <gl-deprecated-button-stub
- class="mr-2"
- href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d"
- size="md"
- variant="secondary"
- >
- <icon-stub
- name="download"
- size="18"
- />
- </gl-deprecated-button-stub>
-
- <delete-button-stub
- buttonclass=""
- buttonvariant="danger"
- hasselecteddesigns="true"
- >
- <icon-stub
- name="remove"
- size="18"
- />
- </delete-button-stub>
-</header>
-`;
diff --git a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_button_spec.js.snap
deleted file mode 100644
index 08662a04f15..00000000000
--- a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_button_spec.js.snap
+++ /dev/null
@@ -1,28 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management pagination button component disables button when no design is passed 1`] = `
-<router-link-stub
- aria-label="Test title"
- class="btn btn-default disabled"
- disabled="true"
- to="[object Object]"
->
- <icon-stub
- name="angle-right"
- size="16"
- />
-</router-link-stub>
-`;
-
-exports[`Design management pagination button component renders router-link 1`] = `
-<router-link-stub
- aria-label="Test title"
- class="btn btn-default"
- to="[object Object]"
->
- <icon-stub
- name="angle-right"
- size="16"
- />
-</router-link-stub>
-`;
diff --git a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_spec.js.snap
deleted file mode 100644
index 0197b4bff79..00000000000
--- a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_spec.js.snap
+++ /dev/null
@@ -1,29 +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 pagination buttons 1`] = `
-<div
- class="d-flex align-items-center"
->
-
- 0 of 2
-
- <div
- class="btn-group ml-3 mr-3"
- >
- <pagination-button-stub
- class="js-previous-design"
- iconname="angle-left"
- title="Go to previous design"
- />
-
- <pagination-button-stub
- class="js-next-design"
- design="[object Object]"
- iconname="angle-right"
- title="Go to next design"
- />
- </div>
-</div>
-`;
diff --git a/spec/frontend/design_management_legacy/components/toolbar/index_spec.js b/spec/frontend/design_management_legacy/components/toolbar/index_spec.js
deleted file mode 100644
index 8207cad4136..00000000000
--- a/spec/frontend/design_management_legacy/components/toolbar/index_spec.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import VueRouter from 'vue-router';
-import { GlDeprecatedButton } from '@gitlab/ui';
-import Toolbar from '~/design_management_legacy/components/toolbar/index.vue';
-import DeleteButton from '~/design_management_legacy/components/delete_button.vue';
-import { DESIGNS_ROUTE_NAME } from '~/design_management_legacy/router/constants';
-
-const localVue = createLocalVue();
-localVue.use(VueRouter);
-const router = new VueRouter();
-
-const RouterLinkStub = {
- props: {
- to: {
- type: Object,
- },
- },
- render(createElement) {
- return createElement('a', {}, this.$slots.default);
- },
-};
-
-describe('Design management toolbar component', () => {
- let wrapper;
-
- function createComponent(isLoading = false, createDesign = true, props) {
- const updatedAt = new Date();
- updatedAt.setHours(updatedAt.getHours() - 1);
-
- wrapper = shallowMount(Toolbar, {
- localVue,
- router,
- propsData: {
- id: '1',
- isLatestVersion: true,
- isLoading,
- isDeleting: false,
- filename: 'test.jpg',
- updatedAt: updatedAt.toString(),
- updatedBy: {
- name: 'Test Name',
- },
- image: '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d',
- ...props,
- },
- stubs: {
- 'router-link': RouterLinkStub,
- },
- });
-
- wrapper.setData({
- permissions: {
- createDesign,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders design and updated data', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('links back to designs list', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- const link = wrapper.find('a');
-
- expect(link.props('to')).toEqual({
- name: DESIGNS_ROUTE_NAME,
- query: {
- version: undefined,
- },
- });
- });
- });
-
- it('renders delete button on latest designs version with logged in user', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(DeleteButton).exists()).toBe(true);
- });
- });
-
- it('does not render delete button on non-latest version', () => {
- createComponent(false, true, { isLatestVersion: false });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(DeleteButton).exists()).toBe(false);
- });
- });
-
- it('does not render delete button when user is not logged in', () => {
- createComponent(false, false);
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(DeleteButton).exists()).toBe(false);
- });
- });
-
- it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns');
- expect(wrapper.emitted().delete).toBeTruthy();
- });
- });
-
- it('renders download button with correct link', () => {
- expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(
- '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d',
- );
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management_legacy/components/toolbar/pagination_button_spec.js
deleted file mode 100644
index d2153adca45..00000000000
--- a/spec/frontend/design_management_legacy/components/toolbar/pagination_button_spec.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import VueRouter from 'vue-router';
-import PaginationButton from '~/design_management_legacy/components/toolbar/pagination_button.vue';
-import { DESIGN_ROUTE_NAME } from '~/design_management_legacy/router/constants';
-
-const localVue = createLocalVue();
-localVue.use(VueRouter);
-const router = new VueRouter();
-
-describe('Design management pagination button component', () => {
- let wrapper;
-
- function createComponent(design = null) {
- wrapper = shallowMount(PaginationButton, {
- localVue,
- router,
- propsData: {
- design,
- title: 'Test title',
- iconName: 'angle-right',
- },
- stubs: ['router-link'],
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('disables button when no design is passed', () => {
- createComponent();
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('renders router-link', () => {
- createComponent({ id: '2' });
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- describe('designLink', () => {
- it('returns empty link when design is null', () => {
- createComponent();
-
- expect(wrapper.vm.designLink).toEqual({});
- });
-
- it('returns design link', () => {
- createComponent({ id: '2', filename: 'test' });
-
- wrapper.vm.$router.replace('/root/test-project/issues/1/designs/test?version=1');
-
- expect(wrapper.vm.designLink).toEqual({
- name: DESIGN_ROUTE_NAME,
- params: { id: 'test' },
- query: { version: '1' },
- });
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/toolbar/pagination_spec.js b/spec/frontend/design_management_legacy/components/toolbar/pagination_spec.js
deleted file mode 100644
index 21b55113a6e..00000000000
--- a/spec/frontend/design_management_legacy/components/toolbar/pagination_spec.js
+++ /dev/null
@@ -1,79 +0,0 @@
-/* global Mousetrap */
-import 'mousetrap';
-import { shallowMount } from '@vue/test-utils';
-import Pagination from '~/design_management_legacy/components/toolbar/pagination.vue';
-import { DESIGN_ROUTE_NAME } from '~/design_management_legacy/router/constants';
-
-const push = jest.fn();
-const $router = {
- push,
-};
-
-const $route = {
- path: '/designs/design-2',
- query: {},
-};
-
-describe('Design management pagination component', () => {
- let wrapper;
-
- function createComponent() {
- wrapper = shallowMount(Pagination, {
- propsData: {
- id: '2',
- },
- mocks: {
- $router,
- $route,
- },
- });
- }
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('hides components when designs are empty', () => {
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('renders pagination buttons', () => {
- wrapper.setData({
- designs: [{ id: '1' }, { id: '2' }],
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- describe('keyboard buttons navigation', () => {
- beforeEach(() => {
- wrapper.setData({
- designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }],
- });
- });
-
- it('routes to previous design on Left button', () => {
- Mousetrap.trigger('left');
- expect(push).toHaveBeenCalledWith({
- name: DESIGN_ROUTE_NAME,
- params: { id: '1' },
- query: {},
- });
- });
-
- it('routes to next design on Right button', () => {
- Mousetrap.trigger('right');
- expect(push).toHaveBeenCalledWith({
- name: DESIGN_ROUTE_NAME,
- params: { id: '3' },
- query: {},
- });
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management_legacy/components/upload/__snapshots__/button_spec.js.snap
deleted file mode 100644
index 27c0ba589e6..00000000000
--- a/spec/frontend/design_management_legacy/components/upload/__snapshots__/button_spec.js.snap
+++ /dev/null
@@ -1,79 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management upload button component renders inverted upload design button 1`] = `
-<div
- isinverted="true"
->
- <gl-deprecated-button-stub
- size="md"
- title="Adding a design with the same filename replaces the file in a new version."
- variant="success"
- >
-
- Upload designs
-
- <!---->
- </gl-deprecated-button-stub>
-
- <input
- accept="image/*"
- class="hide"
- multiple="multiple"
- name="design_file"
- type="file"
- />
-</div>
-`;
-
-exports[`Design management upload button component renders loading icon 1`] = `
-<div>
- <gl-deprecated-button-stub
- disabled="true"
- size="md"
- title="Adding a design with the same filename replaces the file in a new version."
- variant="success"
- >
-
- Upload designs
-
- <gl-loading-icon-stub
- class="ml-1"
- color="orange"
- inline="true"
- label="Loading"
- size="sm"
- />
- </gl-deprecated-button-stub>
-
- <input
- accept="image/*"
- class="hide"
- multiple="multiple"
- name="design_file"
- type="file"
- />
-</div>
-`;
-
-exports[`Design management upload button component renders upload design button 1`] = `
-<div>
- <gl-deprecated-button-stub
- size="md"
- title="Adding a design with the same filename replaces the file in a new version."
- variant="success"
- >
-
- Upload designs
-
- <!---->
- </gl-deprecated-button-stub>
-
- <input
- accept="image/*"
- class="hide"
- multiple="multiple"
- name="design_file"
- type="file"
- />
-</div>
-`;
diff --git a/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_dropzone_spec.js.snap
deleted file mode 100644
index 0737b9729a2..00000000000
--- a/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_dropzone_spec.js.snap
+++ /dev/null
@@ -1,455 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = `
-<div
- class="w-100 position-relative"
->
- <button
- class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
- >
- <div
- class="d-flex-center flex-column text-center"
- >
- <gl-icon-stub
- class="mb-4"
- name="doc-new"
- size="48"
- />
-
- <p>
- <gl-sprintf-stub
- message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
- />
- </p>
- </div>
- </button>
-
- <input
- accept="image/*"
- class="hide"
- multiple="multiple"
- name="design_file"
- type="file"
- />
-
- <transition-stub
- name="design-dropzone-fade"
- >
- <div
- class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
- style=""
- >
- <div
- class="mw-50 text-center"
- style="display: none;"
- >
- <h3>
- Oh no!
- </h3>
-
- <span>
- You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
- </span>
- </div>
-
- <div
- class="mw-50 text-center"
- style=""
- >
- <h3>
- Incoming!
- </h3>
-
- <span>
- Drop your designs to start your upload.
- </span>
- </div>
- </div>
- </transition-stub>
-</div>
-`;
-
-exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = `
-<div
- class="w-100 position-relative"
->
- <button
- class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
- >
- <div
- class="d-flex-center flex-column text-center"
- >
- <gl-icon-stub
- class="mb-4"
- name="doc-new"
- size="48"
- />
-
- <p>
- <gl-sprintf-stub
- message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
- />
- </p>
- </div>
- </button>
-
- <input
- accept="image/*"
- class="hide"
- multiple="multiple"
- name="design_file"
- type="file"
- />
-
- <transition-stub
- name="design-dropzone-fade"
- >
- <div
- class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
- style=""
- >
- <div
- class="mw-50 text-center"
- style="display: none;"
- >
- <h3>
- Oh no!
- </h3>
-
- <span>
- You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
- </span>
- </div>
-
- <div
- class="mw-50 text-center"
- style=""
- >
- <h3>
- Incoming!
- </h3>
-
- <span>
- Drop your designs to start your upload.
- </span>
- </div>
- </div>
- </transition-stub>
-</div>
-`;
-
-exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = `
-<div
- class="w-100 position-relative"
->
- <button
- class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
- >
- <div
- class="d-flex-center flex-column text-center"
- >
- <gl-icon-stub
- class="mb-4"
- name="doc-new"
- size="48"
- />
-
- <p>
- <gl-sprintf-stub
- message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
- />
- </p>
- </div>
- </button>
-
- <input
- accept="image/*"
- class="hide"
- multiple="multiple"
- name="design_file"
- type="file"
- />
-
- <transition-stub
- name="design-dropzone-fade"
- >
- <div
- class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
- style=""
- >
- <div
- class="mw-50 text-center"
- >
- <h3>
- Oh no!
- </h3>
-
- <span>
- You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
- </span>
- </div>
-
- <div
- class="mw-50 text-center"
- style="display: none;"
- >
- <h3>
- Incoming!
- </h3>
-
- <span>
- Drop your designs to start your upload.
- </span>
- </div>
- </div>
- </transition-stub>
-</div>
-`;
-
-exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = `
-<div
- class="w-100 position-relative"
->
- <button
- class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
- >
- <div
- class="d-flex-center flex-column text-center"
- >
- <gl-icon-stub
- class="mb-4"
- name="doc-new"
- size="48"
- />
-
- <p>
- <gl-sprintf-stub
- message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
- />
- </p>
- </div>
- </button>
-
- <input
- accept="image/*"
- class="hide"
- multiple="multiple"
- name="design_file"
- type="file"
- />
-
- <transition-stub
- name="design-dropzone-fade"
- >
- <div
- class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
- style=""
- >
- <div
- class="mw-50 text-center"
- >
- <h3>
- Oh no!
- </h3>
-
- <span>
- You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
- </span>
- </div>
-
- <div
- class="mw-50 text-center"
- style="display: none;"
- >
- <h3>
- Incoming!
- </h3>
-
- <span>
- Drop your designs to start your upload.
- </span>
- </div>
- </div>
- </transition-stub>
-</div>
-`;
-
-exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = `
-<div
- class="w-100 position-relative"
->
- <button
- class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
- >
- <div
- class="d-flex-center flex-column text-center"
- >
- <gl-icon-stub
- class="mb-4"
- name="doc-new"
- size="48"
- />
-
- <p>
- <gl-sprintf-stub
- message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
- />
- </p>
- </div>
- </button>
-
- <input
- accept="image/*"
- class="hide"
- multiple="multiple"
- name="design_file"
- type="file"
- />
-
- <transition-stub
- name="design-dropzone-fade"
- >
- <div
- class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
- style="display: none;"
- >
- <div
- class="mw-50 text-center"
- >
- <h3>
- Oh no!
- </h3>
-
- <span>
- You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
- </span>
- </div>
-
- <div
- class="mw-50 text-center"
- style="display: none;"
- >
- <h3>
- Incoming!
- </h3>
-
- <span>
- Drop your designs to start your upload.
- </span>
- </div>
- </div>
- </transition-stub>
-</div>
-`;
-
-exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = `
-<div
- class="w-100 position-relative"
->
- <button
- class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
- >
- <div
- class="d-flex-center flex-column text-center"
- >
- <gl-icon-stub
- class="mb-4"
- name="doc-new"
- size="48"
- />
-
- <p>
- <gl-sprintf-stub
- message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
- />
- </p>
- </div>
- </button>
-
- <input
- accept="image/*"
- class="hide"
- multiple="multiple"
- name="design_file"
- type="file"
- />
-
- <transition-stub
- name="design-dropzone-fade"
- >
- <div
- class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
- style="display: none;"
- >
- <div
- class="mw-50 text-center"
- >
- <h3>
- Oh no!
- </h3>
-
- <span>
- You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
- </span>
- </div>
-
- <div
- class="mw-50 text-center"
- style="display: none;"
- >
- <h3>
- Incoming!
- </h3>
-
- <span>
- Drop your designs to start your upload.
- </span>
- </div>
- </div>
- </transition-stub>
-</div>
-`;
-
-exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = `
-<div
- class="w-100 position-relative"
->
- <div>
- dropzone slot
- </div>
-
- <transition-stub
- name="design-dropzone-fade"
- >
- <div
- class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
- style="display: none;"
- >
- <div
- class="mw-50 text-center"
- >
- <h3>
- Oh no!
- </h3>
-
- <span>
- You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
- </span>
- </div>
-
- <div
- class="mw-50 text-center"
- style="display: none;"
- >
- <h3>
- Incoming!
- </h3>
-
- <span>
- Drop your designs to start your upload.
- </span>
- </div>
- </div>
- </transition-stub>
-</div>
-`;
diff --git a/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
deleted file mode 100644
index d34b925f33d..00000000000
--- a/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
+++ /dev/null
@@ -1,111 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management design version dropdown component renders design version dropdown button 1`] = `
-<gl-deprecated-dropdown-stub
- class="design-version-dropdown"
- issueiid=""
- projectpath=""
- text="Showing Latest Version"
- variant="link"
->
- <gl-deprecated-dropdown-item-stub>
- <router-link-stub
- class="d-flex js-version-link"
- to="[object Object]"
- >
- <div
- class="flex-grow-1 ml-2"
- >
- <div>
- <strong>
- Version 2
-
- <span>
- (latest)
- </span>
- </strong>
- </div>
- </div>
-
- <i
- class="fa fa-check float-right gl-mr-2"
- />
- </router-link-stub>
- </gl-deprecated-dropdown-item-stub>
- <gl-deprecated-dropdown-item-stub>
- <router-link-stub
- class="d-flex js-version-link"
- to="[object Object]"
- >
- <div
- class="flex-grow-1 ml-2"
- >
- <div>
- <strong>
- Version 1
-
- <!---->
- </strong>
- </div>
- </div>
-
- <!---->
- </router-link-stub>
- </gl-deprecated-dropdown-item-stub>
-</gl-deprecated-dropdown-stub>
-`;
-
-exports[`Design management design version dropdown component renders design version list 1`] = `
-<gl-deprecated-dropdown-stub
- class="design-version-dropdown"
- issueiid=""
- projectpath=""
- text="Showing Latest Version"
- variant="link"
->
- <gl-deprecated-dropdown-item-stub>
- <router-link-stub
- class="d-flex js-version-link"
- to="[object Object]"
- >
- <div
- class="flex-grow-1 ml-2"
- >
- <div>
- <strong>
- Version 2
-
- <span>
- (latest)
- </span>
- </strong>
- </div>
- </div>
-
- <i
- class="fa fa-check float-right gl-mr-2"
- />
- </router-link-stub>
- </gl-deprecated-dropdown-item-stub>
- <gl-deprecated-dropdown-item-stub>
- <router-link-stub
- class="d-flex js-version-link"
- to="[object Object]"
- >
- <div
- class="flex-grow-1 ml-2"
- >
- <div>
- <strong>
- Version 1
-
- <!---->
- </strong>
- </div>
- </div>
-
- <!---->
- </router-link-stub>
- </gl-deprecated-dropdown-item-stub>
-</gl-deprecated-dropdown-stub>
-`;
diff --git a/spec/frontend/design_management_legacy/components/upload/button_spec.js b/spec/frontend/design_management_legacy/components/upload/button_spec.js
deleted file mode 100644
index dde5c694194..00000000000
--- a/spec/frontend/design_management_legacy/components/upload/button_spec.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import UploadButton from '~/design_management_legacy/components/upload/button.vue';
-
-describe('Design management upload button component', () => {
- let wrapper;
-
- function createComponent(isSaving = false, isInverted = false) {
- wrapper = shallowMount(UploadButton, {
- propsData: {
- isSaving,
- isInverted,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders upload design button', () => {
- createComponent();
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('renders inverted upload design button', () => {
- createComponent(false, true);
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('renders loading icon', () => {
- createComponent(true);
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- describe('onFileUploadChange', () => {
- it('emits upload event', () => {
- createComponent();
-
- wrapper.vm.onFileUploadChange({ target: { files: 'test' } });
-
- expect(wrapper.emitted().upload[0]).toEqual(['test']);
- });
- });
-
- describe('openFileUpload', () => {
- it('triggers click on input', () => {
- createComponent();
-
- const clickSpy = jest.spyOn(wrapper.find('input').element, 'click');
-
- wrapper.vm.openFileUpload();
-
- expect(clickSpy).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/upload/design_dropzone_spec.js b/spec/frontend/design_management_legacy/components/upload/design_dropzone_spec.js
deleted file mode 100644
index 1907a3124a6..00000000000
--- a/spec/frontend/design_management_legacy/components/upload/design_dropzone_spec.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import DesignDropzone from '~/design_management_legacy/components/upload/design_dropzone.vue';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-
-jest.mock('~/flash');
-
-describe('Design management dropzone component', () => {
- let wrapper;
-
- const mockDragEvent = ({ types = ['Files'], files = [] }) => {
- return { dataTransfer: { types, files } };
- };
-
- const findDropzoneCard = () => wrapper.find('.design-dropzone-card');
-
- function createComponent({ slots = {}, data = {} } = {}) {
- wrapper = shallowMount(DesignDropzone, {
- slots,
- data() {
- return data;
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when slot provided', () => {
- it('renders dropzone with slot content', () => {
- createComponent({
- slots: {
- default: ['<div>dropzone slot</div>'],
- },
- });
-
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- describe('when no slot provided', () => {
- it('renders default dropzone card', () => {
- createComponent();
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('triggers click event on file input element when clicked', () => {
- createComponent();
- const clickSpy = jest.spyOn(wrapper.find('input').element, 'click');
-
- findDropzoneCard().trigger('click');
- expect(clickSpy).toHaveBeenCalled();
- });
- });
-
- describe('when dragging', () => {
- it.each`
- description | eventPayload
- ${'is empty'} | ${{}}
- ${'contains text'} | ${mockDragEvent({ types: ['text'] })}
- ${'contains files and text'} | ${mockDragEvent({ types: ['Files', 'text'] })}
- ${'contains files'} | ${mockDragEvent({ types: ['Files'] })}
- `('renders correct template when drag event $description', ({ eventPayload }) => {
- createComponent();
-
- wrapper.trigger('dragenter', eventPayload);
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('renders correct template when dragging stops', () => {
- createComponent();
-
- wrapper.trigger('dragenter');
- return wrapper.vm
- .$nextTick()
- .then(() => {
- wrapper.trigger('dragleave');
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
- });
-
- describe('when dropping', () => {
- it('emits upload event', () => {
- createComponent();
- const mockFile = { name: 'test', type: 'image/jpg' };
- const mockEvent = mockDragEvent({ files: [mockFile] });
-
- wrapper.trigger('dragenter', mockEvent);
- return wrapper.vm
- .$nextTick()
- .then(() => {
- wrapper.trigger('drop', mockEvent);
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(wrapper.emitted().change[0]).toEqual([[mockFile]]);
- });
- });
- });
-
- describe('ondrop', () => {
- const mockData = { dragCounter: 1, isDragDataValid: true };
-
- describe('when drag data is valid', () => {
- it('emits upload event for valid files', () => {
- createComponent({ data: mockData });
-
- const mockFile = { type: 'image/jpg' };
- const mockEvent = mockDragEvent({ files: [mockFile] });
-
- wrapper.vm.ondrop(mockEvent);
- expect(wrapper.emitted().change[0]).toEqual([[mockFile]]);
- });
-
- it('calls createFlash when files are invalid', () => {
- createComponent({ data: mockData });
-
- const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] });
-
- wrapper.vm.ondrop(mockEvent);
- expect(createFlash).toHaveBeenCalledTimes(1);
- });
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management_legacy/components/upload/design_version_dropdown_spec.js
deleted file mode 100644
index 7fb85f357c7..00000000000
--- a/spec/frontend/design_management_legacy/components/upload/design_version_dropdown_spec.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
-import DesignVersionDropdown from '~/design_management_legacy/components/upload/design_version_dropdown.vue';
-import mockAllVersions from './mock_data/all_versions';
-
-const LATEST_VERSION_ID = 3;
-const PREVIOUS_VERSION_ID = 2;
-
-const designRouteFactory = versionId => ({
- path: `/designs?version=${versionId}`,
- query: {
- version: `${versionId}`,
- },
-});
-
-const MOCK_ROUTE = {
- path: '/designs',
- query: {},
-};
-
-describe('Design management design version dropdown component', () => {
- let wrapper;
-
- function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) {
- wrapper = shallowMount(DesignVersionDropdown, {
- propsData: {
- projectPath: '',
- issueIid: '',
- },
- mocks: {
- $route,
- },
- stubs: ['router-link'],
- });
-
- wrapper.setData({
- allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions,
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const findVersionLink = index => wrapper.findAll('.js-version-link').at(index);
-
- it('renders design version dropdown button', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('renders design version list', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- describe('selected version name', () => {
- it('has "latest" on most recent version item', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findVersionLink(0).text()).toContain('latest');
- });
- });
- });
-
- describe('versions list', () => {
- it('displays latest version text by default', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlDeprecatedDropdown).attributes('text')).toBe(
- 'Showing Latest Version',
- );
- });
- });
-
- it('displays latest version text when only 1 version is present', () => {
- createComponent({ maxVersions: 1 });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlDeprecatedDropdown).attributes('text')).toBe(
- 'Showing Latest Version',
- );
- });
- });
-
- it('displays version text when the current version is not the latest', () => {
- createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlDeprecatedDropdown).attributes('text')).toBe(`Showing Version #1`);
- });
- });
-
- it('displays latest version text when the current version is the latest', () => {
- createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlDeprecatedDropdown).attributes('text')).toBe(
- 'Showing Latest Version',
- );
- });
- });
-
- it('should have the same length as apollo query', () => {
- createComponent();
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.findAll(GlDeprecatedDropdownItem)).toHaveLength(
- wrapper.vm.allVersions.length,
- );
- });
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/components/upload/mock_data/all_versions.js b/spec/frontend/design_management_legacy/components/upload/mock_data/all_versions.js
deleted file mode 100644
index e76bbd261bd..00000000000
--- a/spec/frontend/design_management_legacy/components/upload/mock_data/all_versions.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export default [
- {
- node: {
- id: 'gid://gitlab/DesignManagement::Version/3',
- sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55',
- },
- },
- {
- node: {
- id: 'gid://gitlab/DesignManagement::Version/2',
- sha: '5b063fef0cd7213b312db65b30e24f057df21b20',
- },
- },
-];
diff --git a/spec/frontend/design_management_legacy/mock_data/all_versions.js b/spec/frontend/design_management_legacy/mock_data/all_versions.js
deleted file mode 100644
index c389fdb8747..00000000000
--- a/spec/frontend/design_management_legacy/mock_data/all_versions.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export default [
- {
- node: {
- id: 'gid://gitlab/DesignManagement::Version/1',
- sha: 'b389071a06c153509e11da1f582005b316667001',
- },
- },
-];
diff --git a/spec/frontend/design_management_legacy/mock_data/design.js b/spec/frontend/design_management_legacy/mock_data/design.js
deleted file mode 100644
index 675198b9408..00000000000
--- a/spec/frontend/design_management_legacy/mock_data/design.js
+++ /dev/null
@@ -1,74 +0,0 @@
-export default {
- id: 'design-id',
- filename: 'test.jpg',
- fullPath: 'full-design-path',
- image: 'test.jpg',
- updatedAt: '01-01-2019',
- updatedBy: {
- name: 'test',
- },
- issue: {
- title: 'My precious issue',
- webPath: 'full-issue-path',
- webUrl: 'full-issue-url',
- participants: {
- edges: [
- {
- node: {
- name: 'Administrator',
- username: 'root',
- webUrl: 'link-to-author',
- avatarUrl: 'link-to-avatar',
- },
- },
- ],
- },
- },
- discussions: {
- nodes: [
- {
- id: 'discussion-id',
- replyId: 'discussion-reply-id',
- resolved: false,
- notes: {
- nodes: [
- {
- id: 'note-id',
- body: '123',
- author: {
- name: 'Administrator',
- username: 'root',
- webUrl: 'link-to-author',
- avatarUrl: 'link-to-avatar',
- },
- },
- ],
- },
- },
- {
- id: 'discussion-resolved',
- replyId: 'discussion-reply-resolved',
- resolved: true,
- notes: {
- nodes: [
- {
- id: 'note-resolved',
- body: '123',
- author: {
- name: 'Administrator',
- username: 'root',
- webUrl: 'link-to-author',
- avatarUrl: 'link-to-avatar',
- },
- },
- ],
- },
- },
- ],
- },
- diffRefs: {
- headSha: 'headSha',
- baseSha: 'baseSha',
- startSha: 'startSha',
- },
-};
diff --git a/spec/frontend/design_management_legacy/mock_data/designs.js b/spec/frontend/design_management_legacy/mock_data/designs.js
deleted file mode 100644
index 07f5c1b7457..00000000000
--- a/spec/frontend/design_management_legacy/mock_data/designs.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import design from './design';
-
-export default {
- project: {
- issue: {
- designCollection: {
- designs: {
- edges: [
- {
- node: design,
- },
- ],
- },
- },
- },
- },
-};
diff --git a/spec/frontend/design_management_legacy/mock_data/no_designs.js b/spec/frontend/design_management_legacy/mock_data/no_designs.js
deleted file mode 100644
index 9db0ffcade2..00000000000
--- a/spec/frontend/design_management_legacy/mock_data/no_designs.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export default {
- project: {
- issue: {
- designCollection: {
- designs: {
- edges: [],
- },
- },
- },
- },
-};
diff --git a/spec/frontend/design_management_legacy/mock_data/notes.js b/spec/frontend/design_management_legacy/mock_data/notes.js
deleted file mode 100644
index 80cb3944786..00000000000
--- a/spec/frontend/design_management_legacy/mock_data/notes.js
+++ /dev/null
@@ -1,46 +0,0 @@
-export default [
- {
- id: 'note-id-1',
- index: 1,
- position: {
- height: 100,
- width: 100,
- x: 10,
- y: 15,
- },
- author: {
- name: 'John',
- webUrl: 'link-to-john-profile',
- },
- createdAt: '2020-05-08T07:10:45Z',
- userPermissions: {
- adminNote: true,
- },
- discussion: {
- id: 'discussion-id-1',
- },
- resolved: false,
- },
- {
- id: 'note-id-2',
- index: 2,
- position: {
- height: 50,
- width: 50,
- x: 25,
- y: 25,
- },
- author: {
- name: 'Mary',
- webUrl: 'link-to-mary-profile',
- },
- createdAt: '2020-05-08T07:10:45Z',
- userPermissions: {
- adminNote: true,
- },
- discussion: {
- id: 'discussion-id-2',
- },
- resolved: true,
- },
-];
diff --git a/spec/frontend/design_management_legacy/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_legacy/pages/__snapshots__/index_spec.js.snap
deleted file mode 100644
index 3ba63fd14f0..00000000000
--- a/spec/frontend/design_management_legacy/pages/__snapshots__/index_spec.js.snap
+++ /dev/null
@@ -1,263 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management index page designs does not render toolbar when there is no permission 1`] = `
-<div>
- <!---->
-
- <div
- class="mt-4"
- >
- <ol
- class="list-unstyled row"
- >
- <li
- class="col-md-6 col-lg-4 mb-3"
- >
- <design-dropzone-stub
- class="design-list-item"
- />
- </li>
-
- <li
- class="col-md-6 col-lg-4 mb-3"
- >
- <design-dropzone-stub>
- <design-stub
- event="NONE"
- filename="design-1-name"
- id="design-1"
- image="design-1-image"
- notescount="0"
- />
- </design-dropzone-stub>
-
- <!---->
- </li>
- <li
- class="col-md-6 col-lg-4 mb-3"
- >
- <design-dropzone-stub>
- <design-stub
- event="NONE"
- filename="design-2-name"
- id="design-2"
- image="design-2-image"
- notescount="1"
- />
- </design-dropzone-stub>
-
- <!---->
- </li>
- <li
- class="col-md-6 col-lg-4 mb-3"
- >
- <design-dropzone-stub>
- <design-stub
- event="NONE"
- filename="design-3-name"
- id="design-3"
- image="design-3-image"
- notescount="0"
- />
- </design-dropzone-stub>
-
- <!---->
- </li>
- </ol>
- </div>
-
- <router-view-stub
- name="default"
- />
-</div>
-`;
-
-exports[`Design management index page designs renders designs list and header with upload button 1`] = `
-<div>
- <header
- class="row-content-block border-top-0 p-2 d-flex"
- >
- <div
- class="d-flex justify-content-between align-items-center w-100"
- >
- <design-version-dropdown-stub />
-
- <div
- class="qa-selector-toolbar d-flex"
- >
- <gl-deprecated-button-stub
- class="mr-2 js-select-all"
- size="md"
- variant="link"
- >
- Select all
- </gl-deprecated-button-stub>
-
- <div>
- <delete-button-stub
- buttonclass="btn-danger btn-inverted mr-2"
- buttonvariant=""
- >
-
- Delete selected
-
- <!---->
- </delete-button-stub>
- </div>
-
- <upload-button-stub />
- </div>
- </div>
- </header>
-
- <div
- class="mt-4"
- >
- <ol
- class="list-unstyled row"
- >
- <li
- class="col-md-6 col-lg-4 mb-3"
- >
- <design-dropzone-stub
- class="design-list-item"
- />
- </li>
-
- <li
- class="col-md-6 col-lg-4 mb-3"
- >
- <design-dropzone-stub>
- <design-stub
- event="NONE"
- filename="design-1-name"
- id="design-1"
- image="design-1-image"
- notescount="0"
- />
- </design-dropzone-stub>
-
- <input
- class="design-checkbox"
- type="checkbox"
- />
- </li>
- <li
- class="col-md-6 col-lg-4 mb-3"
- >
- <design-dropzone-stub>
- <design-stub
- event="NONE"
- filename="design-2-name"
- id="design-2"
- image="design-2-image"
- notescount="1"
- />
- </design-dropzone-stub>
-
- <input
- class="design-checkbox"
- type="checkbox"
- />
- </li>
- <li
- class="col-md-6 col-lg-4 mb-3"
- >
- <design-dropzone-stub>
- <design-stub
- event="NONE"
- filename="design-3-name"
- id="design-3"
- image="design-3-image"
- notescount="0"
- />
- </design-dropzone-stub>
-
- <input
- class="design-checkbox"
- type="checkbox"
- />
- </li>
- </ol>
- </div>
-
- <router-view-stub
- name="default"
- />
-</div>
-`;
-
-exports[`Design management index page designs renders error 1`] = `
-<div>
- <!---->
-
- <div
- class="mt-4"
- >
- <gl-alert-stub
- dismisslabel="Dismiss"
- primarybuttonlink=""
- primarybuttontext=""
- secondarybuttonlink=""
- secondarybuttontext=""
- title=""
- variant="danger"
- >
-
- An error occurred while loading designs. Please try again.
-
- </gl-alert-stub>
- </div>
-
- <router-view-stub
- name="default"
- />
-</div>
-`;
-
-exports[`Design management index page designs renders loading icon 1`] = `
-<div>
- <!---->
-
- <div
- class="mt-4"
- >
- <gl-loading-icon-stub
- color="orange"
- label="Loading"
- size="md"
- />
- </div>
-
- <router-view-stub
- name="default"
- />
-</div>
-`;
-
-exports[`Design management index page when has no designs renders empty text 1`] = `
-<div>
- <!---->
-
- <div
- class="mt-4"
- >
- <ol
- class="list-unstyled row"
- >
- <li
- class="col-md-6 col-lg-4 mb-3"
- >
- <design-dropzone-stub
- class="design-list-item"
- />
- </li>
-
- </ol>
- </div>
-
- <router-view-stub
- name="default"
- />
-</div>
-`;
diff --git a/spec/frontend/design_management_legacy/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_legacy/pages/design/__snapshots__/index_spec.js.snap
deleted file mode 100644
index dc5baf37fc6..00000000000
--- a/spec/frontend/design_management_legacy/pages/design/__snapshots__/index_spec.js.snap
+++ /dev/null
@@ -1,216 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management design index page renders design index 1`] = `
-<div
- class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
->
- <div
- class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"
- >
- <design-destroyer-stub
- filenames="test.jpg"
- iid="1"
- projectpath=""
- />
-
- <!---->
-
- <design-presentation-stub
- discussions="[object Object],[object Object]"
- image="test.jpg"
- imagename="test.jpg"
- scale="1"
- />
-
- <div
- class="design-scaler-wrapper position-absolute mb-4 d-flex-center"
- >
- <design-scaler-stub />
- </div>
- </div>
-
- <div
- class="image-notes"
- >
- <h2
- class="gl-font-weight-bold gl-mt-0"
- >
-
- My precious issue
-
- </h2>
-
- <a
- class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block"
- href="full-issue-url"
- >
- ull-issue-path
- </a>
-
- <participants-stub
- class="gl-mb-4"
- numberoflessparticipants="7"
- participants="[object Object]"
- />
-
- <!---->
-
- <design-discussion-stub
- data-testid="unresolved-discussion"
- designid="test"
- discussion="[object Object]"
- discussionwithopenform=""
- markdownpreviewpath="//preview_markdown?target_type=Issue"
- noteableid="design-id"
- />
-
- <gl-button-stub
- category="primary"
- class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
- data-testid="resolved-comments"
- icon="chevron-right"
- id="resolved-comments"
- size="medium"
- variant="link"
- >
- Resolved Comments (1)
-
- </gl-button-stub>
-
- <gl-popover-stub
- container="popovercontainer"
- cssclasses=""
- placement="top"
- show="true"
- target="resolved-comments"
- title="Resolved Comments"
- >
- <p>
-
- Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below
-
- </p>
-
- <a
- href="#"
- rel="noopener noreferrer"
- target="_blank"
- >
- Learn more about resolving comments
- </a>
- </gl-popover-stub>
-
- <gl-collapse-stub
- class="gl-mt-3"
- >
- <design-discussion-stub
- data-testid="resolved-discussion"
- designid="test"
- discussion="[object Object]"
- discussionwithopenform=""
- markdownpreviewpath="//preview_markdown?target_type=Issue"
- noteableid="design-id"
- />
- </gl-collapse-stub>
-
- </div>
-</div>
-`;
-
-exports[`Design management design index page sets loading state 1`] = `
-<div
- class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
->
- <gl-loading-icon-stub
- class="align-self-center"
- color="orange"
- label="Loading"
- size="xl"
- />
-</div>
-`;
-
-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 w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
->
- <div
- class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"
- >
- <design-destroyer-stub
- filenames="test.jpg"
- iid="1"
- projectpath=""
- />
-
- <div
- class="p-3"
- >
- <gl-alert-stub
- dismissible="true"
- dismisslabel="Dismiss"
- primarybuttonlink=""
- primarybuttontext=""
- secondarybuttonlink=""
- secondarybuttontext=""
- title=""
- variant="danger"
- >
-
- woops
-
- </gl-alert-stub>
- </div>
-
- <design-presentation-stub
- discussions=""
- image="test.jpg"
- imagename="test.jpg"
- scale="1"
- />
-
- <div
- class="design-scaler-wrapper position-absolute mb-4 d-flex-center"
- >
- <design-scaler-stub />
- </div>
- </div>
-
- <div
- class="image-notes"
- >
- <h2
- class="gl-font-weight-bold gl-mt-0"
- >
-
- My precious issue
-
- </h2>
-
- <a
- class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block"
- href="full-issue-url"
- >
- ull-issue-path
- </a>
-
- <participants-stub
- class="gl-mb-4"
- numberoflessparticipants="7"
- participants="[object Object]"
- />
-
- <h2
- class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
- data-testid="new-discussion-disclaimer"
- >
-
- Click the image where you'd like to start a new discussion
-
- </h2>
-
- <!---->
-
- </div>
-</div>
-`;
diff --git a/spec/frontend/design_management_legacy/pages/design/index_spec.js b/spec/frontend/design_management_legacy/pages/design/index_spec.js
deleted file mode 100644
index 5eb4158c715..00000000000
--- a/spec/frontend/design_management_legacy/pages/design/index_spec.js
+++ /dev/null
@@ -1,291 +0,0 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import VueRouter from 'vue-router';
-import { GlAlert } from '@gitlab/ui';
-import { ApolloMutation } from 'vue-apollo';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-import DesignIndex from '~/design_management_legacy/pages/design/index.vue';
-import DesignSidebar from '~/design_management_legacy/components/design_sidebar.vue';
-import DesignPresentation from '~/design_management_legacy/components/design_presentation.vue';
-import createImageDiffNoteMutation from '~/design_management_legacy/graphql/mutations/create_image_diff_note.mutation.graphql';
-import design from '../../mock_data/design';
-import mockResponseWithDesigns from '../../mock_data/designs';
-import mockResponseNoDesigns from '../../mock_data/no_designs';
-import mockAllVersions from '../../mock_data/all_versions';
-import {
- DESIGN_NOT_FOUND_ERROR,
- DESIGN_VERSION_NOT_EXIST_ERROR,
-} from '~/design_management_legacy/utils/error_messages';
-import { DESIGNS_ROUTE_NAME } from '~/design_management_legacy/router/constants';
-import createRouter from '~/design_management_legacy/router';
-import * as utils from '~/design_management_legacy/utils/design_management_utils';
-import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_legacy/constants';
-
-jest.mock('~/flash');
-jest.mock('mousetrap', () => ({
- bind: jest.fn(),
- unbind: jest.fn(),
-}));
-
-const focusInput = jest.fn();
-
-const DesignReplyForm = {
- template: '<div><textarea ref="textarea"></textarea></div>',
- methods: {
- focusInput,
- },
-};
-
-const localVue = createLocalVue();
-localVue.use(VueRouter);
-
-describe('Design management design index page', () => {
- let wrapper;
- let router;
-
- 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,
- },
- },
- },
- };
-
- const mutate = jest.fn().mockResolvedValue();
-
- const findDiscussionForm = () => wrapper.find(DesignReplyForm);
- const findSidebar = () => wrapper.find(DesignSidebar);
- const findDesignPresentation = () => wrapper.find(DesignPresentation);
-
- function createComponent(loading = false, data = {}) {
- const $apollo = {
- queries: {
- design: {
- loading,
- },
- },
- mutate,
- };
-
- router = createRouter();
-
- wrapper = shallowMount(DesignIndex, {
- propsData: { id: '1' },
- mocks: { $apollo },
- stubs: {
- ApolloMutation,
- DesignSidebar,
- DesignReplyForm,
- },
- data() {
- return {
- issueIid: '1',
- activeDiscussion: {
- id: null,
- source: null,
- },
- ...data,
- };
- },
- localVue,
- router,
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when navigating', () => {
- it('applies fullscreen layout', () => {
- const mockEl = {
- classList: {
- add: jest.fn(),
- remove: jest.fn(),
- },
- };
- jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockEl);
- createComponent(true);
-
- wrapper.vm.$router.push('/designs/test');
- expect(mockEl.classList.add).toHaveBeenCalledTimes(1);
- expect(mockEl.classList.add).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
- });
- });
-
- it('sets loading state', () => {
- createComponent(true);
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('renders design index', () => {
- createComponent(false, { design });
-
- expect(wrapper.element).toMatchSnapshot();
- expect(wrapper.find(GlAlert).exists()).toBe(false);
- });
-
- it('passes correct props to sidebar component', () => {
- createComponent(false, { design });
-
- expect(findSidebar().props()).toEqual({
- design,
- markdownPreviewPath: '//preview_markdown?target_type=Issue',
- resolvedDiscussionsExpanded: false,
- });
- });
-
- it('opens a new discussion form', () => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
- },
- },
- });
-
- findDesignPresentation().vm.$emit('openCommentForm', { x: 0, y: 0 });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findDiscussionForm().exists()).toBe(true);
- });
- });
-
- it('keeps new discussion form focused', () => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
- },
- },
- annotationCoordinates,
- });
-
- findDesignPresentation().vm.$emit('openCommentForm', { x: 10, y: 10 });
-
- expect(focusInput).toHaveBeenCalled();
- });
-
- it('sends a mutation on submitting form and closes form', () => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
- },
- },
- annotationCoordinates,
- comment: newComment,
- });
-
- findDiscussionForm().vm.$emit('submitForm');
- expect(mutate).toHaveBeenCalledWith(createDiscussionMutationVariables);
-
- return wrapper.vm
- .$nextTick()
- .then(() => {
- return mutate({ variables: createDiscussionMutationVariables });
- })
- .then(() => {
- expect(findDiscussionForm().exists()).toBe(false);
- });
- });
-
- it('closes the form and clears the comment on canceling form', () => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
- },
- },
- annotationCoordinates,
- comment: newComment,
- });
-
- findDiscussionForm().vm.$emit('cancelForm');
-
- expect(wrapper.vm.comment).toBe('');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findDiscussionForm().exists()).toBe(false);
- });
- });
-
- describe('with error', () => {
- beforeEach(() => {
- createComponent(false, {
- design: {
- ...design,
- discussions: {
- nodes: [],
- },
- },
- errorMessage: 'woops',
- });
- });
-
- it('GlAlert is rendered in correct position with correct content', () => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- describe('onDesignQueryResult', () => {
- describe('with no designs', () => {
- it('redirects to /designs', () => {
- createComponent(true);
- router.push = jest.fn();
-
- wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false });
- return wrapper.vm.$nextTick().then(() => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR);
- expect(router.push).toHaveBeenCalledTimes(1);
- expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
- });
- });
- });
-
- describe('when no design exists for given version', () => {
- it('redirects to /designs', () => {
- createComponent(true);
- wrapper.setData({
- allVersions: mockAllVersions,
- });
-
- // attempt to query for a version of the design that doesn't exist
- router.push({ query: { version: '999' } });
- router.push = jest.fn();
-
- wrapper.vm.onDesignQueryResult({ data: mockResponseWithDesigns, loading: false });
- return wrapper.vm.$nextTick().then(() => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(DESIGN_VERSION_NOT_EXIST_ERROR);
- expect(router.push).toHaveBeenCalledTimes(1);
- expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
- });
- });
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/pages/index_spec.js b/spec/frontend/design_management_legacy/pages/index_spec.js
deleted file mode 100644
index 5b7512aab7b..00000000000
--- a/spec/frontend/design_management_legacy/pages/index_spec.js
+++ /dev/null
@@ -1,543 +0,0 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { ApolloMutation } from 'vue-apollo';
-import VueRouter from 'vue-router';
-import { GlEmptyState } from '@gitlab/ui';
-import Index from '~/design_management_legacy/pages/index.vue';
-import uploadDesignQuery from '~/design_management_legacy/graphql/mutations/upload_design.mutation.graphql';
-import DesignDestroyer from '~/design_management_legacy/components/design_destroyer.vue';
-import DesignDropzone from '~/design_management_legacy/components/upload/design_dropzone.vue';
-import DeleteButton from '~/design_management_legacy/components/delete_button.vue';
-import { DESIGNS_ROUTE_NAME } from '~/design_management_legacy/router/constants';
-import {
- EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
- EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
-} from '~/design_management_legacy/utils/error_messages';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-import createRouter from '~/design_management_legacy/router';
-import * as utils from '~/design_management_legacy/utils/design_management_utils';
-import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_legacy/constants';
-
-jest.mock('~/flash.js');
-const mockPageEl = {
- classList: {
- remove: jest.fn(),
- },
-};
-jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageEl);
-
-const localVue = createLocalVue();
-const router = createRouter();
-localVue.use(VueRouter);
-
-const mockDesigns = [
- {
- id: 'design-1',
- image: 'design-1-image',
- filename: 'design-1-name',
- event: 'NONE',
- notesCount: 0,
- },
- {
- id: 'design-2',
- image: 'design-2-image',
- filename: 'design-2-name',
- event: 'NONE',
- notesCount: 1,
- },
- {
- id: 'design-3',
- image: 'design-3-image',
- filename: 'design-3-name',
- event: 'NONE',
- notesCount: 0,
- },
-];
-
-const mockVersion = {
- node: {
- id: 'gid://gitlab/DesignManagement::Version/1',
- },
-};
-
-describe('Design management index page', () => {
- let mutate;
- let wrapper;
-
- const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
- const findSelectAllButton = () => wrapper.find('.js-select-all');
- const findToolbar = () => wrapper.find('.qa-selector-toolbar');
- const findDeleteButton = () => wrapper.find(DeleteButton);
- const findDropzone = () => wrapper.findAll(DesignDropzone).at(0);
- const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
-
- function createComponent({
- loading = false,
- designs = [],
- allVersions = [],
- createDesign = true,
- stubs = {},
- mockMutate = jest.fn().mockResolvedValue(),
- } = {}) {
- mutate = mockMutate;
- const $apollo = {
- queries: {
- designs: {
- loading,
- },
- permissions: {
- loading,
- },
- },
- mutate,
- };
-
- wrapper = shallowMount(Index, {
- mocks: { $apollo },
- localVue,
- router,
- stubs: { DesignDestroyer, ApolloMutation, ...stubs },
- attachToDocument: true,
- });
-
- wrapper.setData({
- designs,
- allVersions,
- issueIid: '1',
- permissions: {
- createDesign,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('designs', () => {
- it('renders loading icon', () => {
- createComponent({ loading: true });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('renders error', () => {
- createComponent();
-
- wrapper.setData({ error: true });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('renders a toolbar with buttons when there are designs', () => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findToolbar().exists()).toBe(true);
- });
- });
-
- it('renders designs list and header with upload button', () => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('does not render toolbar when there is no permission', () => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- });
- });
-
- describe('when has no designs', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders empty text', () =>
- wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- }));
-
- it('does not render a toolbar with buttons', () =>
- wrapper.vm.$nextTick().then(() => {
- expect(findToolbar().exists()).toBe(false);
- }));
- });
-
- describe('uploading designs', () => {
- it('calls mutation on upload', () => {
- createComponent({ stubs: { GlEmptyState } });
-
- const mutationVariables = {
- update: expect.anything(),
- context: {
- hasUpload: true,
- },
- mutation: uploadDesignQuery,
- variables: {
- files: [{ name: 'test' }],
- projectPath: '',
- iid: '1',
- },
- optimisticResponse: {
- __typename: 'Mutation',
- designManagementUpload: {
- __typename: 'DesignManagementUploadPayload',
- designs: [
- {
- __typename: 'Design',
- id: expect.anything(),
- image: '',
- imageV432x230: '',
- filename: 'test',
- fullPath: '',
- event: 'NONE',
- notesCount: 0,
- diffRefs: {
- __typename: 'DiffRefs',
- baseSha: '',
- startSha: '',
- headSha: '',
- },
- discussions: {
- __typename: 'DesignDiscussion',
- nodes: [],
- },
- versions: {
- __typename: 'DesignVersionConnection',
- edges: {
- __typename: 'DesignVersionEdge',
- node: {
- __typename: 'DesignVersion',
- id: expect.anything(),
- sha: expect.anything(),
- },
- },
- },
- },
- ],
- skippedDesigns: [],
- errors: [],
- },
- },
- };
-
- return wrapper.vm.$nextTick().then(() => {
- findDropzone().vm.$emit('change', [{ name: 'test' }]);
- expect(mutate).toHaveBeenCalledWith(mutationVariables);
- expect(wrapper.vm.filesToBeSaved).toEqual([{ name: 'test' }]);
- expect(wrapper.vm.isSaving).toBeTruthy();
- });
- });
-
- it('sets isSaving', () => {
- createComponent();
-
- const uploadDesign = wrapper.vm.onUploadDesign([
- {
- name: 'test',
- },
- ]);
-
- expect(wrapper.vm.isSaving).toBe(true);
-
- return uploadDesign.then(() => {
- expect(wrapper.vm.isSaving).toBe(false);
- });
- });
-
- it('updates state appropriately after upload complete', () => {
- createComponent({ stubs: { GlEmptyState } });
- wrapper.setData({ filesToBeSaved: [{ name: 'test' }] });
-
- wrapper.vm.onUploadDesignDone();
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.filesToBeSaved).toEqual([]);
- expect(wrapper.vm.isSaving).toBeFalsy();
- expect(wrapper.vm.isLatestVersion).toBe(true);
- });
- });
-
- it('updates state appropriately after upload error', () => {
- createComponent({ stubs: { GlEmptyState } });
- wrapper.setData({ filesToBeSaved: [{ name: 'test' }] });
-
- wrapper.vm.onUploadDesignError();
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.filesToBeSaved).toEqual([]);
- expect(wrapper.vm.isSaving).toBeFalsy();
- expect(createFlash).toHaveBeenCalled();
-
- createFlash.mockReset();
- });
- });
-
- it('does not call mutation if createDesign is false', () => {
- createComponent({ createDesign: false });
-
- wrapper.vm.onUploadDesign([]);
-
- expect(mutate).not.toHaveBeenCalled();
- });
-
- describe('upload count limit', () => {
- const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
-
- afterEach(() => {
- createFlash.mockReset();
- });
-
- it('does not warn when the max files are uploaded', () => {
- createComponent();
-
- wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT).fill(mockDesigns[0]));
-
- expect(createFlash).not.toHaveBeenCalled();
- });
-
- it('warns when too many files are uploaded', () => {
- createComponent();
-
- wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT + 1).fill(mockDesigns[0]));
-
- expect(createFlash).toHaveBeenCalled();
- });
- });
-
- it('flashes warning if designs are skipped', () => {
- createComponent({
- mockMutate: () =>
- Promise.resolve({
- data: { designManagementUpload: { skippedDesigns: [{ filename: 'test.jpg' }] } },
- }),
- });
-
- const uploadDesign = wrapper.vm.onUploadDesign([
- {
- name: 'test',
- },
- ]);
-
- return uploadDesign.then(() => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(
- 'Upload skipped. test.jpg did not change.',
- 'warning',
- );
- });
- });
-
- describe('dragging onto an existing design', () => {
- beforeEach(() => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
- });
-
- it('calls onUploadDesign with valid upload', () => {
- wrapper.setMethods({
- onUploadDesign: jest.fn(),
- });
-
- const mockUploadPayload = [
- {
- name: mockDesigns[0].filename,
- },
- ];
-
- const designDropzone = findFirstDropzoneWithDesign();
- designDropzone.vm.$emit('change', mockUploadPayload);
-
- expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
- expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith(mockUploadPayload);
- });
-
- it.each`
- description | eventPayload | message
- ${'> 1 file'} | ${[{ name: 'test' }, { name: 'test-2' }]} | ${EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE}
- ${'different filename'} | ${[{ name: 'wrong-name' }]} | ${EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE}
- `('calls createFlash when upload has $description', ({ eventPayload, message }) => {
- const designDropzone = findFirstDropzoneWithDesign();
- designDropzone.vm.$emit('change', eventPayload);
-
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(message);
- });
- });
- });
-
- describe('on latest version when has designs', () => {
- beforeEach(() => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
- });
-
- it('renders design checkboxes', () => {
- expect(findDesignCheckboxes()).toHaveLength(mockDesigns.length);
- });
-
- it('renders toolbar buttons', () => {
- expect(findToolbar().exists()).toBe(true);
- expect(findToolbar().classes()).toContain('d-flex');
- expect(findToolbar().classes()).not.toContain('d-none');
- });
-
- it('adds two designs to selected designs when their checkboxes are checked', () => {
- findDesignCheckboxes()
- .at(0)
- .trigger('click');
-
- return wrapper.vm
- .$nextTick()
- .then(() => {
- findDesignCheckboxes()
- .at(1)
- .trigger('click');
-
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(findDeleteButton().exists()).toBe(true);
- expect(findSelectAllButton().text()).toBe('Deselect all');
- findDeleteButton().vm.$emit('deleteSelectedDesigns');
- const [{ variables }] = mutate.mock.calls[0];
- expect(variables.filenames).toStrictEqual([
- mockDesigns[0].filename,
- mockDesigns[1].filename,
- ]);
- });
- });
-
- it('adds all designs to selected designs when Select All button is clicked', () => {
- findSelectAllButton().vm.$emit('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findDeleteButton().props().hasSelectedDesigns).toBe(true);
- expect(findSelectAllButton().text()).toBe('Deselect all');
- expect(wrapper.vm.selectedDesigns).toEqual(mockDesigns.map(design => design.filename));
- });
- });
-
- it('removes all designs from selected designs when at least one design was selected', () => {
- findDesignCheckboxes()
- .at(0)
- .trigger('click');
-
- return wrapper.vm
- .$nextTick()
- .then(() => {
- findSelectAllButton().vm.$emit('click');
- })
- .then(() => {
- expect(findDeleteButton().props().hasSelectedDesigns).toBe(false);
- expect(findSelectAllButton().text()).toBe('Select all');
- expect(wrapper.vm.selectedDesigns).toEqual([]);
- });
- });
- });
-
- it('on latest version when has no designs does not render toolbar buttons', () => {
- createComponent({ designs: [], allVersions: [mockVersion] });
- expect(findToolbar().exists()).toBe(false);
- });
-
- describe('on non-latest version', () => {
- beforeEach(() => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
-
- router.replace({
- name: DESIGNS_ROUTE_NAME,
- query: {
- version: '2',
- },
- });
- });
-
- it('does not render design checkboxes', () => {
- expect(findDesignCheckboxes()).toHaveLength(0);
- });
-
- it('does not render Delete selected button', () => {
- expect(findDeleteButton().exists()).toBe(false);
- });
-
- it('does not render Select All button', () => {
- expect(findSelectAllButton().exists()).toBe(false);
- });
- });
-
- describe('pasting a design', () => {
- let event;
- beforeEach(() => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
-
- wrapper.setMethods({
- onUploadDesign: jest.fn(),
- });
-
- event = new Event('paste');
-
- router.replace({
- name: DESIGNS_ROUTE_NAME,
- query: {
- version: '2',
- },
- });
- });
-
- it('calls onUploadDesign with valid paste', () => {
- event.clipboardData = {
- files: [{ name: 'image.png', type: 'image/png' }],
- getData: () => 'test.png',
- };
-
- document.dispatchEvent(event);
-
- expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
- expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([
- new File([{ name: 'image.png' }], 'test.png'),
- ]);
- });
-
- it('renames a design if it has an image.png filename', () => {
- event.clipboardData = {
- files: [{ name: 'image.png', type: 'image/png' }],
- getData: () => 'image.png',
- };
-
- document.dispatchEvent(event);
-
- expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
- expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([
- new File([{ name: 'image.png' }], `design_${Date.now()}.png`),
- ]);
- });
-
- it('does not call onUploadDesign with invalid paste', () => {
- event.clipboardData = {
- items: [{ type: 'text/plain' }, { type: 'text' }],
- files: [],
- };
-
- document.dispatchEvent(event);
-
- expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled();
- });
- });
-
- describe('when navigating', () => {
- it('ensures fullscreen layout is not applied', () => {
- createComponent(true);
-
- wrapper.vm.$router.push('/designs');
- expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1);
- expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/router_spec.js b/spec/frontend/design_management_legacy/router_spec.js
deleted file mode 100644
index 5f62793a243..00000000000
--- a/spec/frontend/design_management_legacy/router_spec.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import { mount, createLocalVue } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import VueRouter from 'vue-router';
-import App from '~/design_management_legacy/components/app.vue';
-import Designs from '~/design_management_legacy/pages/index.vue';
-import DesignDetail from '~/design_management_legacy/pages/design/index.vue';
-import createRouter from '~/design_management_legacy/router';
-import {
- ROOT_ROUTE_NAME,
- DESIGNS_ROUTE_NAME,
- DESIGN_ROUTE_NAME,
-} from '~/design_management_legacy/router/constants';
-import '~/commons/bootstrap';
-
-function factory(routeArg) {
- const localVue = createLocalVue();
- localVue.use(VueRouter);
-
- window.gon = { sprite_icons: '' };
-
- const router = createRouter('/');
- if (routeArg !== undefined) {
- router.push(routeArg);
- }
-
- return mount(App, {
- localVue,
- router,
- mocks: {
- $apollo: {
- queries: {
- designs: { loading: true },
- design: { loading: true },
- permissions: { loading: true },
- },
- mutate: jest.fn(),
- },
- },
- });
-}
-
-jest.mock('mousetrap', () => ({
- bind: jest.fn(),
- unbind: jest.fn(),
-}));
-
-describe('Design management router', () => {
- afterEach(() => {
- window.location.hash = '';
- });
-
- describe.each([['/'], [{ name: ROOT_ROUTE_NAME }]])('root route', routeArg => {
- it('pushes home component', () => {
- const wrapper = factory(routeArg);
-
- expect(wrapper.find(Designs).exists()).toBe(true);
- });
- });
-
- describe.each([['/designs'], [{ name: DESIGNS_ROUTE_NAME }]])('designs route', routeArg => {
- it('pushes designs root component', () => {
- const wrapper = factory(routeArg);
-
- expect(wrapper.find(Designs).exists()).toBe(true);
- });
- });
-
- describe.each([['/designs/1'], [{ name: DESIGN_ROUTE_NAME, params: { id: '1' } }]])(
- 'designs detail route',
- routeArg => {
- it('pushes designs detail component', () => {
- const wrapper = factory(routeArg);
-
- return nextTick().then(() => {
- const detail = wrapper.find(DesignDetail);
- expect(detail.exists()).toBe(true);
- expect(detail.props('id')).toEqual('1');
- });
- });
- },
- );
-});
diff --git a/spec/frontend/design_management_legacy/utils/cache_update_spec.js b/spec/frontend/design_management_legacy/utils/cache_update_spec.js
deleted file mode 100644
index dce91b5e59b..00000000000
--- a/spec/frontend/design_management_legacy/utils/cache_update_spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import { InMemoryCache } from 'apollo-cache-inmemory';
-import {
- updateStoreAfterDesignsDelete,
- updateStoreAfterAddDiscussionComment,
- updateStoreAfterAddImageDiffNote,
- updateStoreAfterUploadDesign,
- updateStoreAfterUpdateImageDiffNote,
-} from '~/design_management_legacy/utils/cache_update';
-import {
- designDeletionError,
- ADD_DISCUSSION_COMMENT_ERROR,
- ADD_IMAGE_DIFF_NOTE_ERROR,
- UPDATE_IMAGE_DIFF_NOTE_ERROR,
-} from '~/design_management_legacy/utils/error_messages';
-import design from '../mock_data/design';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-
-jest.mock('~/flash.js');
-
-describe('Design Management cache update', () => {
- const mockErrors = ['code red!'];
-
- let mockStore;
-
- beforeEach(() => {
- mockStore = new InMemoryCache();
- });
-
- describe('error handling', () => {
- it.each`
- fnName | subject | errorMessage | extraArgs
- ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]}
- ${'updateStoreAfterAddDiscussionComment'} | ${updateStoreAfterAddDiscussionComment} | ${ADD_DISCUSSION_COMMENT_ERROR} | ${[]}
- ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]}
- ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]}
- ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]}
- `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => {
- expect(createFlash).not.toHaveBeenCalled();
- expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow();
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(errorMessage);
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/utils/design_management_utils_spec.js b/spec/frontend/design_management_legacy/utils/design_management_utils_spec.js
deleted file mode 100644
index 97e85a24a35..00000000000
--- a/spec/frontend/design_management_legacy/utils/design_management_utils_spec.js
+++ /dev/null
@@ -1,176 +0,0 @@
-import {
- extractCurrentDiscussion,
- extractDiscussions,
- findVersionId,
- designUploadOptimisticResponse,
- updateImageDiffNoteOptimisticResponse,
- isValidDesignFile,
- extractDesign,
-} from '~/design_management_legacy/utils/design_management_utils';
-import mockResponseNoDesigns from '../mock_data/no_designs';
-import mockResponseWithDesigns from '../mock_data/designs';
-import mockDesign from '../mock_data/design';
-
-jest.mock('lodash/uniqueId', () => () => 1);
-
-describe('extractCurrentDiscussion', () => {
- let discussions;
-
- beforeEach(() => {
- discussions = {
- nodes: [
- { id: 101, payload: 'w' },
- { id: 102, payload: 'x' },
- { id: 103, payload: 'y' },
- { id: 104, payload: 'z' },
- ],
- };
- });
-
- it('finds the relevant discussion if it exists', () => {
- const id = 103;
- expect(extractCurrentDiscussion(discussions, id)).toEqual({ id, payload: 'y' });
- });
-
- it('returns null if the relevant discussion does not exist', () => {
- expect(extractCurrentDiscussion(discussions, 0)).not.toBeDefined();
- });
-});
-
-describe('extractDiscussions', () => {
- let discussions;
-
- beforeEach(() => {
- discussions = {
- nodes: [
- { id: 1, notes: { nodes: ['a'] } },
- { id: 2, notes: { nodes: ['b'] } },
- { id: 3, notes: { nodes: ['c'] } },
- { id: 4, notes: { nodes: ['d'] } },
- ],
- };
- });
-
- it('discards the edges.node artifacts of GraphQL', () => {
- expect(extractDiscussions(discussions)).toEqual([
- { id: 1, notes: ['a'], index: 1 },
- { id: 2, notes: ['b'], index: 2 },
- { id: 3, notes: ['c'], index: 3 },
- { id: 4, notes: ['d'], index: 4 },
- ]);
- });
-});
-
-describe('version parser', () => {
- it('correctly extracts version ID from a valid version string', () => {
- const testVersionId = '123';
- const testVersionString = `gid://gitlab/DesignManagement::Version/${testVersionId}`;
-
- expect(findVersionId(testVersionString)).toEqual(testVersionId);
- });
-
- it('fails to extract version ID from an invalid version string', () => {
- const testInvalidVersionString = `gid://gitlab/DesignManagement::Version`;
-
- expect(findVersionId(testInvalidVersionString)).toBeUndefined();
- });
-});
-
-describe('optimistic responses', () => {
- it('correctly generated for designManagementUpload', () => {
- const expectedResponse = {
- __typename: 'Mutation',
- designManagementUpload: {
- __typename: 'DesignManagementUploadPayload',
- designs: [
- {
- __typename: 'Design',
- id: -1,
- image: '',
- imageV432x230: '',
- filename: 'test',
- fullPath: '',
- notesCount: 0,
- event: 'NONE',
- diffRefs: { __typename: 'DiffRefs', baseSha: '', startSha: '', headSha: '' },
- discussions: { __typename: 'DesignDiscussion', nodes: [] },
- versions: {
- __typename: 'DesignVersionConnection',
- edges: {
- __typename: 'DesignVersionEdge',
- node: { __typename: 'DesignVersion', id: -1, sha: -1 },
- },
- },
- },
- ],
- errors: [],
- skippedDesigns: [],
- },
- };
- expect(designUploadOptimisticResponse([{ name: 'test' }])).toEqual(expectedResponse);
- });
-
- it('correctly generated for updateImageDiffNoteOptimisticResponse', () => {
- const mockNote = {
- id: 'test-note-id',
- };
-
- const mockPosition = {
- x: 10,
- y: 10,
- width: 10,
- height: 10,
- };
-
- const expectedResponse = {
- __typename: 'Mutation',
- updateImageDiffNote: {
- __typename: 'UpdateImageDiffNotePayload',
- note: {
- ...mockNote,
- position: mockPosition,
- },
- errors: [],
- },
- };
- expect(updateImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual(
- expectedResponse,
- );
- });
-});
-
-describe('isValidDesignFile', () => {
- // test every filetype that Design Management supports
- // https://docs.gitlab.com/ee/user/project/issues/design_management.html#limitations
- it.each`
- mimetype | isValid
- ${'image/svg'} | ${true}
- ${'image/png'} | ${true}
- ${'image/jpg'} | ${true}
- ${'image/jpeg'} | ${true}
- ${'image/gif'} | ${true}
- ${'image/bmp'} | ${true}
- ${'image/tiff'} | ${true}
- ${'image/ico'} | ${true}
- ${'image/svg'} | ${true}
- ${'video/mpeg'} | ${false}
- ${'audio/midi'} | ${false}
- ${'application/octet-stream'} | ${false}
- `('returns $isValid for file type $mimetype', ({ mimetype, isValid }) => {
- expect(isValidDesignFile({ type: mimetype })).toBe(isValid);
- });
-});
-
-describe('extractDesign', () => {
- describe('with no designs', () => {
- it('returns undefined', () => {
- expect(extractDesign(mockResponseNoDesigns)).toBeUndefined();
- });
- });
-
- describe('with designs', () => {
- it('returns the first design available', () => {
- expect(extractDesign(mockResponseWithDesigns)).toEqual(mockDesign);
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/utils/error_messages_spec.js b/spec/frontend/design_management_legacy/utils/error_messages_spec.js
deleted file mode 100644
index 489ac23da4e..00000000000
--- a/spec/frontend/design_management_legacy/utils/error_messages_spec.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import {
- designDeletionError,
- designUploadSkippedWarning,
-} from '~/design_management_legacy/utils/error_messages';
-
-const mockFilenames = n =>
- Array(n)
- .fill(0)
- .map((_, i) => ({ filename: `${i + 1}.jpg` }));
-
-describe('Error message', () => {
- describe('designDeletionError', () => {
- const singularMsg = 'Could not delete a design. Please try again.';
- const pluralMsg = 'Could not delete designs. Please try again.';
-
- describe('when [singular=true]', () => {
- it.each([[undefined], [true]])('uses singular grammar', singularOption => {
- expect(designDeletionError({ singular: singularOption })).toEqual(singularMsg);
- });
- });
-
- describe('when [singular=false]', () => {
- it('uses plural grammar', () => {
- expect(designDeletionError({ singular: false })).toEqual(pluralMsg);
- });
- });
- });
-
- describe.each([
- [[], [], null],
- [mockFilenames(1), mockFilenames(1), 'Upload skipped. 1.jpg did not change.'],
- [
- mockFilenames(2),
- mockFilenames(2),
- 'Upload skipped. The designs you tried uploading did not change.',
- ],
- [
- mockFilenames(2),
- mockFilenames(1),
- 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg.',
- ],
- [
- mockFilenames(6),
- mockFilenames(5),
- 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg.',
- ],
- [
- mockFilenames(7),
- mockFilenames(6),
- 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 1 more.',
- ],
- [
- mockFilenames(8),
- mockFilenames(7),
- 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 2 more.',
- ],
- ])('designUploadSkippedWarning', (uploadedFiles, skippedFiles, expected) => {
- it('returns expected warning message', () => {
- expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected);
- });
- });
-});
diff --git a/spec/frontend/design_management_legacy/utils/tracking_spec.js b/spec/frontend/design_management_legacy/utils/tracking_spec.js
deleted file mode 100644
index a59cf80c906..00000000000
--- a/spec/frontend/design_management_legacy/utils/tracking_spec.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { mockTracking } from 'helpers/tracking_helper';
-import { trackDesignDetailView } from '~/design_management_legacy/utils/tracking';
-
-function getTrackingSpy(key) {
- return mockTracking(key, undefined, jest.spyOn);
-}
-
-describe('Tracking Events', () => {
- describe('trackDesignDetailView', () => {
- const eventKey = 'projects:issues:design';
- const eventName = 'view_design';
-
- it('trackDesignDetailView fires a tracking event when called', () => {
- const trackingSpy = getTrackingSpy(eventKey);
-
- trackDesignDetailView();
-
- expect(trackingSpy).toHaveBeenCalledWith(
- eventKey,
- eventName,
- expect.objectContaining({
- label: eventName,
- context: {
- schema: expect.any(String),
- data: {
- 'design-version-number': 1,
- 'design-is-current-version': false,
- 'internal-object-referrer': '',
- 'design-collection-owner': '',
- },
- },
- }),
- );
- });
-
- it('trackDesignDetailView allows to customize the value payload', () => {
- const trackingSpy = getTrackingSpy(eventKey);
-
- trackDesignDetailView('from-a-test', 'test', 100, true);
-
- expect(trackingSpy).toHaveBeenCalledWith(
- eventKey,
- eventName,
- expect.objectContaining({
- label: eventName,
- context: {
- schema: expect.any(String),
- data: {
- 'design-version-number': 100,
- 'design-is-current-version': true,
- 'internal-object-referrer': 'from-a-test',
- 'design-collection-owner': 'test',
- },
- },
- }),
- );
- });
- });
-});
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index ac046ddc203..cd3a6aa0e28 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -1,6 +1,6 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'spec/test_constants';
import Mousetrap from 'mousetrap';
@@ -9,6 +9,7 @@ import NoChanges from '~/diffs/components/no_changes.vue';
import DiffFile from '~/diffs/components/diff_file.vue';
import CompareVersions from '~/diffs/components/compare_versions.vue';
import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
+import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue';
import CommitWidget from '~/diffs/components/commit_widget.vue';
import TreeList from '~/diffs/components/tree_list.vue';
import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants';
@@ -22,6 +23,10 @@ const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`;
const COMMIT_URL = '[BASE URL]/OLD';
const UPDATED_COMMIT_URL = '[BASE URL]/NEW';
+function getCollapsedFilesWarning(wrapper) {
+ return wrapper.find(CollapsedFilesWarning);
+}
+
describe('diffs/components/app', () => {
const oldMrTabs = window.mrTabs;
let store;
@@ -108,7 +113,6 @@ describe('diffs/components/app', () => {
};
jest.spyOn(window, 'requestIdleCallback').mockImplementation(fn => fn());
createComponent();
- jest.spyOn(wrapper.vm, 'fetchDiffFiles').mockImplementation(fetchResolver);
jest.spyOn(wrapper.vm, 'fetchDiffFilesMeta').mockImplementation(fetchResolver);
jest.spyOn(wrapper.vm, 'fetchDiffFilesBatch').mockImplementation(fetchResolver);
jest.spyOn(wrapper.vm, 'fetchCoverageFiles').mockImplementation(fetchResolver);
@@ -139,37 +143,21 @@ describe('diffs/components/app', () => {
parallel_diff_lines: ['line'],
};
- function expectFetchToOccur({
- vueInstance,
- done = () => {},
- batch = false,
- existingFiles = 1,
- } = {}) {
+ function expectFetchToOccur({ vueInstance, done = () => {}, existingFiles = 1 } = {}) {
vueInstance.$nextTick(() => {
expect(vueInstance.diffFiles.length).toEqual(existingFiles);
-
- if (!batch) {
- expect(vueInstance.fetchDiffFiles).toHaveBeenCalled();
- expect(vueInstance.fetchDiffFilesBatch).not.toHaveBeenCalled();
- } else {
- expect(vueInstance.fetchDiffFiles).not.toHaveBeenCalled();
- expect(vueInstance.fetchDiffFilesBatch).toHaveBeenCalled();
- }
+ expect(vueInstance.fetchDiffFilesBatch).toHaveBeenCalled();
done();
});
}
- beforeEach(() => {
- wrapper.vm.glFeatures.singleMrDiffView = true;
- });
-
it('fetches diffs if it has none', done => {
wrapper.vm.isLatestVersion = () => false;
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
- expectFetchToOccur({ vueInstance: wrapper.vm, batch: false, existingFiles: 0, done });
+ expectFetchToOccur({ vueInstance: wrapper.vm, existingFiles: 0, done });
});
it('fetches diffs if it has both view styles, but no lines in either', done => {
@@ -200,89 +188,46 @@ describe('diffs/components/app', () => {
});
it('fetches batch diffs if it has none', done => {
- wrapper.vm.glFeatures.diffsBatchLoad = true;
-
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
- expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, existingFiles: 0, done });
+ expectFetchToOccur({ vueInstance: wrapper.vm, existingFiles: 0, done });
});
it('fetches batch diffs if it has both view styles, but no lines in either', done => {
- wrapper.vm.glFeatures.diffsBatchLoad = true;
-
store.state.diffs.diffFiles.push(noLinesDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
- expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done });
+ expectFetchToOccur({ vueInstance: wrapper.vm, done });
});
it('fetches batch diffs if it only has inline view style', done => {
- wrapper.vm.glFeatures.diffsBatchLoad = true;
-
store.state.diffs.diffFiles.push(inlineLinesDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
- expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done });
+ expectFetchToOccur({ vueInstance: wrapper.vm, done });
});
it('fetches batch diffs if it only has parallel view style', done => {
- wrapper.vm.glFeatures.diffsBatchLoad = true;
-
store.state.diffs.diffFiles.push(parallelLinesDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
- expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done });
- });
-
- it('does not fetch diffs if it has already fetched both styles of diff', () => {
- wrapper.vm.glFeatures.diffsBatchLoad = false;
-
- store.state.diffs.diffFiles.push(fullDiff);
- store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
-
- expect(wrapper.vm.diffFiles.length).toEqual(1);
- expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled();
- expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled();
+ expectFetchToOccur({ vueInstance: wrapper.vm, done });
});
it('does not fetch batch diffs if it has already fetched both styles of diff', () => {
- wrapper.vm.glFeatures.diffsBatchLoad = true;
-
store.state.diffs.diffFiles.push(fullDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expect(wrapper.vm.diffFiles.length).toEqual(1);
- expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled();
});
});
- it('calls fetchDiffFiles if diffsBatchLoad is not enabled', done => {
- expect(wrapper.vm.diffFilesLength).toEqual(0);
- wrapper.vm.glFeatures.diffsBatchLoad = false;
- wrapper.vm.fetchData(false);
-
- expect(wrapper.vm.fetchDiffFiles).toHaveBeenCalled();
- setImmediate(() => {
- expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled();
- expect(wrapper.vm.fetchDiffFilesMeta).not.toHaveBeenCalled();
- expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled();
- expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled();
- expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
- expect(wrapper.vm.diffFilesLength).toEqual(100);
- expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled();
-
- done();
- });
- });
-
it('calls batch methods if diffsBatchLoad is enabled, and not latest version', done => {
expect(wrapper.vm.diffFilesLength).toEqual(0);
- wrapper.vm.glFeatures.diffsBatchLoad = true;
wrapper.vm.isLatestVersion = () => false;
wrapper.vm.fetchData(false);
- expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled();
setImmediate(() => {
expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled();
@@ -297,10 +242,8 @@ describe('diffs/components/app', () => {
it('calls batch methods if diffsBatchLoad is enabled, and latest version', done => {
expect(wrapper.vm.diffFilesLength).toEqual(0);
- wrapper.vm.glFeatures.diffsBatchLoad = true;
wrapper.vm.fetchData(false);
- expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled();
setImmediate(() => {
expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled();
@@ -320,7 +263,7 @@ describe('diffs/components/app', () => {
state.diffs.isParallelView = false;
});
- expect(wrapper.contains('.container-limited.limit-container-width')).toBe(true);
+ expect(wrapper.find('.container-limited.limit-container-width').exists()).toBe(true);
});
it('does not add container-limiting classes when showFileTree is false with inline diffs', () => {
@@ -329,7 +272,7 @@ describe('diffs/components/app', () => {
state.diffs.isParallelView = false;
});
- expect(wrapper.contains('.container-limited.limit-container-width')).toBe(false);
+ expect(wrapper.find('.container-limited.limit-container-width').exists()).toBe(false);
});
it('does not add container-limiting classes when isFluidLayout', () => {
@@ -337,7 +280,7 @@ describe('diffs/components/app', () => {
state.diffs.isParallelView = false;
});
- expect(wrapper.contains('.container-limited.limit-container-width')).toBe(false);
+ expect(wrapper.find('.container-limited.limit-container-width').exists()).toBe(false);
});
it('displays loading icon on loading', () => {
@@ -345,7 +288,7 @@ describe('diffs/components/app', () => {
state.diffs.isLoading = true;
});
- expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('displays loading icon on batch loading', () => {
@@ -353,20 +296,20 @@ describe('diffs/components/app', () => {
state.diffs.isBatchLoading = true;
});
- expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('displays diffs container when not loading', () => {
createComponent();
- expect(wrapper.contains(GlLoadingIcon)).toBe(false);
- expect(wrapper.contains('#diffs')).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find('#diffs').exists()).toBe(true);
});
it('does not show commit info', () => {
createComponent();
- expect(wrapper.contains('.blob-commit-info')).toBe(false);
+ expect(wrapper.find('.blob-commit-info').exists()).toBe(false);
});
describe('row highlighting', () => {
@@ -442,7 +385,7 @@ describe('diffs/components/app', () => {
it('renders empty state when no diff files exist', () => {
createComponent();
- expect(wrapper.contains(NoChanges)).toBe(true);
+ expect(wrapper.find(NoChanges).exists()).toBe(true);
});
it('does not render empty state when diff files exist', () => {
@@ -452,7 +395,7 @@ describe('diffs/components/app', () => {
});
});
- expect(wrapper.contains(NoChanges)).toBe(false);
+ expect(wrapper.find(NoChanges).exists()).toBe(false);
expect(wrapper.findAll(DiffFile).length).toBe(1);
});
@@ -462,7 +405,7 @@ describe('diffs/components/app', () => {
state.diffs.mergeRequestDiff = mergeRequestDiff;
});
- expect(wrapper.contains(NoChanges)).toBe(false);
+ expect(wrapper.find(NoChanges).exists()).toBe(false);
});
});
@@ -722,7 +665,7 @@ describe('diffs/components/app', () => {
state.diffs.mergeRequestDiff = mergeRequestDiff;
});
- expect(wrapper.contains(CompareVersions)).toBe(true);
+ expect(wrapper.find(CompareVersions).exists()).toBe(true);
expect(wrapper.find(CompareVersions).props()).toEqual(
expect.objectContaining({
mergeRequestDiffs: diffsMockData,
@@ -730,24 +673,51 @@ describe('diffs/components/app', () => {
);
});
- it('should render hidden files warning if render overflow warning is present', () => {
- createComponent({}, ({ state }) => {
- state.diffs.renderOverflowWarning = true;
- state.diffs.realSize = '5';
- state.diffs.plainDiffPath = 'plain diff path';
- state.diffs.emailPatchPath = 'email patch path';
- state.diffs.size = 1;
+ describe('warnings', () => {
+ describe('hidden files', () => {
+ it('should render hidden files warning if render overflow warning is present', () => {
+ createComponent({}, ({ state }) => {
+ state.diffs.renderOverflowWarning = true;
+ state.diffs.realSize = '5';
+ state.diffs.plainDiffPath = 'plain diff path';
+ state.diffs.emailPatchPath = 'email patch path';
+ state.diffs.size = 1;
+ });
+
+ expect(wrapper.find(HiddenFilesWarning).exists()).toBe(true);
+ expect(wrapper.find(HiddenFilesWarning).props()).toEqual(
+ expect.objectContaining({
+ total: '5',
+ plainDiffPath: 'plain diff path',
+ emailPatchPath: 'email patch path',
+ visible: 1,
+ }),
+ );
+ });
});
- expect(wrapper.contains(HiddenFilesWarning)).toBe(true);
- expect(wrapper.find(HiddenFilesWarning).props()).toEqual(
- expect.objectContaining({
- total: '5',
- plainDiffPath: 'plain diff path',
- emailPatchPath: 'email patch path',
- visible: 1,
- }),
- );
+ describe('collapsed files', () => {
+ it('should render the collapsed files warning if there are any collapsed files', () => {
+ createComponent({}, ({ state }) => {
+ state.diffs.diffFiles = [{ viewer: { collapsed: true } }];
+ });
+
+ expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
+ });
+
+ it('should not render the collapsed files warning if the user has dismissed the alert already', async () => {
+ createComponent({}, ({ state }) => {
+ state.diffs.diffFiles = [{ viewer: { collapsed: true } }];
+ });
+
+ expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
+
+ wrapper.vm.collapsedWarningDismissed = true;
+ await wrapper.vm.$nextTick();
+
+ expect(getCollapsedFilesWarning(wrapper).exists()).toBe(false);
+ });
+ });
});
it('should display commit widget if store has a commit', () => {
@@ -757,7 +727,7 @@ describe('diffs/components/app', () => {
};
});
- expect(wrapper.contains(CommitWidget)).toBe(true);
+ expect(wrapper.find(CommitWidget).exists()).toBe(true);
});
it('should display diff file if there are diff files', () => {
@@ -765,7 +735,7 @@ describe('diffs/components/app', () => {
state.diffs.diffFiles.push({ sha: '123' });
});
- expect(wrapper.contains(DiffFile)).toBe(true);
+ expect(wrapper.find(DiffFile).exists()).toBe(true);
});
it('should render tree list', () => {
@@ -843,13 +813,16 @@ describe('diffs/components/app', () => {
});
describe('pagination', () => {
+ const fileByFileNav = () => wrapper.find('[data-testid="file-by-file-navigation"]');
+ const paginator = () => fileByFileNav().find(GlPagination);
+
it('sets previous button as disabled', () => {
createComponent({ viewDiffsFileByFile: true }, ({ state }) => {
state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' });
});
- expect(wrapper.find('[data-testid="singleFilePrevious"]').props('disabled')).toBe(true);
- expect(wrapper.find('[data-testid="singleFileNext"]').props('disabled')).toBe(false);
+ expect(paginator().attributes('prevpage')).toBe(undefined);
+ expect(paginator().attributes('nextpage')).toBe('2');
});
it('sets next button as disabled', () => {
@@ -858,17 +831,26 @@ describe('diffs/components/app', () => {
state.diffs.currentDiffFileId = '312';
});
- expect(wrapper.find('[data-testid="singleFilePrevious"]').props('disabled')).toBe(false);
- expect(wrapper.find('[data-testid="singleFileNext"]').props('disabled')).toBe(true);
+ expect(paginator().attributes('prevpage')).toBe('1');
+ expect(paginator().attributes('nextpage')).toBe(undefined);
+ });
+
+ it("doesn't display when there's fewer than 2 files", () => {
+ createComponent({ viewDiffsFileByFile: true }, ({ state }) => {
+ state.diffs.diffFiles.push({ file_hash: '123' });
+ state.diffs.currentDiffFileId = '123';
+ });
+
+ expect(fileByFileNav().exists()).toBe(false);
});
it.each`
- currentDiffFileId | button | index
- ${'123'} | ${'singleFileNext'} | ${1}
- ${'312'} | ${'singleFilePrevious'} | ${0}
+ currentDiffFileId | targetFile
+ ${'123'} | ${2}
+ ${'312'} | ${1}
`(
- 'it calls navigateToDiffFileIndex with $index when $button is clicked',
- ({ currentDiffFileId, button, index }) => {
+ 'it calls navigateToDiffFileIndex with $index when $link is clicked',
+ async ({ currentDiffFileId, targetFile }) => {
createComponent({ viewDiffsFileByFile: true }, ({ state }) => {
state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' });
state.diffs.currentDiffFileId = currentDiffFileId;
@@ -876,11 +858,11 @@ describe('diffs/components/app', () => {
jest.spyOn(wrapper.vm, 'navigateToDiffFileIndex');
- wrapper.find(`[data-testid="${button}"]`).vm.$emit('click');
+ paginator().vm.$emit('input', targetFile);
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith(index);
- });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith(targetFile - 1);
},
);
});
diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
new file mode 100644
index 00000000000..670eab5472f
--- /dev/null
+++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
@@ -0,0 +1,88 @@
+import Vuex from 'vuex';
+import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
+import createStore from '~/diffs/store/modules';
+import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue';
+import { CENTERED_LIMITED_CONTAINER_CLASSES } from '~/diffs/constants';
+
+const propsData = {
+ limited: true,
+ mergeable: true,
+ resolutionPath: 'a-path',
+};
+const limitedClasses = CENTERED_LIMITED_CONTAINER_CLASSES.split(' ');
+
+describe('CollapsedFilesWarning', () => {
+ const localVue = createLocalVue();
+ let store;
+ let wrapper;
+
+ localVue.use(Vuex);
+
+ const getAlertActionButton = () =>
+ wrapper.find(CollapsedFilesWarning).find('button.gl-alert-action:first-child');
+ const getAlertCloseButton = () => wrapper.find(CollapsedFilesWarning).find('button');
+
+ const createComponent = (props = {}, { full } = { full: false }) => {
+ const mounter = full ? mount : shallowMount;
+ store = new Vuex.Store({
+ modules: {
+ diffs: createStore(),
+ },
+ });
+
+ wrapper = mounter(CollapsedFilesWarning, {
+ propsData: { ...propsData, ...props },
+ localVue,
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ limited | containerClasses
+ ${true} | ${limitedClasses}
+ ${false} | ${[]}
+ `(
+ 'has the correct container classes when limited is $limited',
+ ({ limited, containerClasses }) => {
+ createComponent({ limited });
+
+ expect(wrapper.classes()).toEqual(containerClasses);
+ },
+ );
+
+ it.each`
+ present | dismissed
+ ${false} | ${true}
+ ${true} | ${false}
+ `('toggles the alert when dismissed is $dismissed', ({ present, dismissed }) => {
+ createComponent({ dismissed });
+
+ expect(wrapper.find('[data-testid="root"]').exists()).toBe(present);
+ });
+
+ it('dismisses the component when the alert "x" is clicked', async () => {
+ createComponent({}, { full: true });
+
+ expect(wrapper.find('[data-testid="root"]').exists()).toBe(true);
+
+ getAlertCloseButton().element.click();
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="root"]').exists()).toBe(false);
+ });
+
+ it('triggers the expandAllFiles action when the alert action button is clicked', () => {
+ createComponent({}, { full: true });
+
+ jest.spyOn(wrapper.vm.$store, 'dispatch').mockReturnValue(undefined);
+
+ getAlertActionButton().vm.$emit('click');
+
+ expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/expandAllFiles', undefined);
+ });
+});
diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js
index 0df951d43a7..c48445790f7 100644
--- a/spec/frontend/diffs/components/commit_item_spec.js
+++ b/spec/frontend/diffs/components/commit_item_spec.js
@@ -24,8 +24,7 @@ describe('diffs/components/commit_item', () => {
const getTitleElement = () => wrapper.find('.commit-row-message.item-title');
const getDescElement = () => wrapper.find('pre.commit-row-description');
- const getDescExpandElement = () =>
- wrapper.find('.commit-content .text-expander.js-toggle-button');
+ const getDescExpandElement = () => wrapper.find('.commit-content .js-toggle-button');
const getShaElement = () => wrapper.find('.commit-sha-group');
const getAvatarElement = () => wrapper.find('.user-avatar-link');
const getCommitterElement = () => wrapper.find('.committer');
diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js
index 7fdbc791589..b3dfc71260c 100644
--- a/spec/frontend/diffs/components/compare_versions_spec.js
+++ b/spec/frontend/diffs/components/compare_versions_spec.js
@@ -2,7 +2,6 @@ import { trimText } from 'helpers/text_helper';
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import CompareVersionsComponent from '~/diffs/components/compare_versions.vue';
-import Icon from '~/vue_shared/components/icon.vue';
import { createStore } from '~/mr_notes/stores';
import diffsMockData from '../mock_data/merge_request_diffs';
import getDiffWithCommit from '../mock_data/diff_with_commit';
@@ -51,7 +50,7 @@ describe('CompareVersions', () => {
expect(treeListBtn.exists()).toBe(true);
expect(treeListBtn.attributes('title')).toBe('Hide file browser');
- expect(treeListBtn.find(Icon).props('name')).toBe('file-tree');
+ expect(treeListBtn.props('icon')).toBe('file-tree');
});
it('should render comparison dropdowns with correct values', () => {
diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js
index b78895f9e55..6d0120d888e 100644
--- a/spec/frontend/diffs/components/diff_content_spec.js
+++ b/spec/frontend/diffs/components/diff_content_spec.js
@@ -177,23 +177,19 @@ describe('DiffContent', () => {
});
wrapper.find(NoteForm).vm.$emit('handleFormUpdate', noteStub);
- expect(saveDiffDiscussionMock).toHaveBeenCalledWith(
- expect.any(Object),
- {
- note: noteStub,
- formData: {
- noteableData: expect.any(Object),
- diffFile: currentDiffFile,
- positionType: IMAGE_DIFF_POSITION_TYPE,
- x: undefined,
- y: undefined,
- width: undefined,
- height: undefined,
- noteableType: undefined,
- },
+ expect(saveDiffDiscussionMock).toHaveBeenCalledWith(expect.any(Object), {
+ note: noteStub,
+ formData: {
+ noteableData: expect.any(Object),
+ diffFile: currentDiffFile,
+ positionType: IMAGE_DIFF_POSITION_TYPE,
+ x: undefined,
+ y: undefined,
+ width: undefined,
+ height: undefined,
+ noteableType: undefined,
},
- undefined,
- );
+ });
});
});
});
diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js
index 83becc7a20a..96b76183cee 100644
--- a/spec/frontend/diffs/components/diff_discussions_spec.js
+++ b/spec/frontend/diffs/components/diff_discussions_spec.js
@@ -1,9 +1,9 @@
import { mount, createLocalVue } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import DiffDiscussions from '~/diffs/components/diff_discussions.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import NoteableDiscussion from '~/notes/components/noteable_discussion.vue';
import DiscussionNotes from '~/notes/components/discussion_notes.vue';
-import Icon from '~/vue_shared/components/icon.vue';
import { createStore } from '~/mr_notes/stores';
import '~/behaviors/markdown/render_gfm';
import discussionsMockData from '../mock_data/diff_discussions';
@@ -51,7 +51,7 @@ describe('DiffDiscussions', () => {
const diffNotesToggle = findDiffNotesToggle();
expect(diffNotesToggle.exists()).toBe(true);
- expect(diffNotesToggle.find(Icon).exists()).toBe(true);
+ expect(diffNotesToggle.find(GlIcon).exists()).toBe(true);
expect(diffNotesToggle.classes('diff-notes-collapse')).toBe(true);
});
diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
index b8aca4ad86b..81e08f09f62 100644
--- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js
+++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
@@ -10,7 +10,6 @@ import diffFileMockData from '../mock_data/diff_file';
const EXPAND_UP_CLASS = '.js-unfold';
const EXPAND_DOWN_CLASS = '.js-unfold-down';
-const LINE_TO_USE = 5;
const lineSources = {
[INLINE_DIFF_VIEW_TYPE]: 'highlighted_diff_lines',
[PARALLEL_DIFF_VIEW_TYPE]: 'parallel_diff_lines',
@@ -66,7 +65,7 @@ describe('DiffExpansionCell', () => {
beforeEach(() => {
mockFile = cloneDeep(diffFileMockData);
- mockLine = getLine(mockFile, INLINE_DIFF_VIEW_TYPE, LINE_TO_USE);
+ mockLine = getLine(mockFile, INLINE_DIFF_VIEW_TYPE, 8);
store = createStore();
store.state.diffs.diffFiles = [mockFile];
jest.spyOn(store, 'dispatch').mockReturnValue(Promise.resolve());
@@ -88,7 +87,7 @@ describe('DiffExpansionCell', () => {
const findExpandUp = () => vm.$el.querySelector(EXPAND_UP_CLASS);
const findExpandDown = () => vm.$el.querySelector(EXPAND_DOWN_CLASS);
- const findExpandAll = () => getByText(vm.$el, 'Show unchanged lines');
+ const findExpandAll = () => getByText(vm.$el, 'Show all unchanged lines');
describe('top row', () => {
it('should have "expand up" and "show all" option', () => {
@@ -126,12 +125,12 @@ describe('DiffExpansionCell', () => {
describe('any row', () => {
[
- { diffViewType: INLINE_DIFF_VIEW_TYPE, file: { parallel_diff_lines: [] } },
- { diffViewType: PARALLEL_DIFF_VIEW_TYPE, file: { highlighted_diff_lines: [] } },
- ].forEach(({ diffViewType, file }) => {
+ { diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: { parallel_diff_lines: [] } },
+ { diffViewType: PARALLEL_DIFF_VIEW_TYPE, lineIndex: 7, file: { highlighted_diff_lines: [] } },
+ ].forEach(({ diffViewType, file, lineIndex }) => {
describe(`with diffViewType (${diffViewType})`, () => {
beforeEach(() => {
- mockLine = getLine(mockFile, diffViewType, LINE_TO_USE);
+ mockLine = getLine(mockFile, diffViewType, lineIndex);
store.state.diffs.diffFiles = [{ ...mockFile, ...file }];
store.state.diffs.diffViewType = diffViewType;
});
@@ -189,10 +188,10 @@ describe('DiffExpansionCell', () => {
});
it('on expand down clicked, dispatch loadMoreLines', () => {
- mockFile[lineSources[diffViewType]][LINE_TO_USE + 1] = cloneDeep(
- mockFile[lineSources[diffViewType]][LINE_TO_USE],
+ mockFile[lineSources[diffViewType]][lineIndex + 1] = cloneDeep(
+ mockFile[lineSources[diffViewType]][lineIndex],
);
- const nextLine = getLine(mockFile, diffViewType, LINE_TO_USE + 1);
+ const nextLine = getLine(mockFile, diffViewType, lineIndex + 1);
nextLine.meta_data.old_pos = 300;
nextLine.meta_data.new_pos = 300;
diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js
index 671dced080c..a0cad32b9fb 100644
--- a/spec/frontend/diffs/components/diff_file_header_spec.js
+++ b/spec/frontend/diffs/components/diff_file_header_spec.js
@@ -1,9 +1,10 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import { GlIcon } from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import EditButton from '~/diffs/components/edit_button.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import Icon from '~/vue_shared/components/icon.vue';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
import { truncateSha } from '~/lib/utils/text_utility';
import { diffViewerModes } from '~/ide/constants';
@@ -26,12 +27,16 @@ const diffFile = Object.freeze(
}),
);
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('DiffFileHeader component', () => {
let wrapper;
+ let mockStoreConfig;
const diffHasExpandedDiscussionsResultMock = jest.fn();
const diffHasDiscussionsResultMock = jest.fn();
- const mockStoreConfig = {
+ const defaultMockStoreConfig = {
state: {},
modules: {
diffs: {
@@ -44,6 +49,7 @@ describe('DiffFileHeader component', () => {
toggleFileDiscussions: jest.fn(),
toggleFileDiscussionWrappers: jest.fn(),
toggleFullDiff: jest.fn(),
+ toggleActiveFileByHash: jest.fn(),
},
},
},
@@ -55,6 +61,8 @@ describe('DiffFileHeader component', () => {
diffHasExpandedDiscussionsResultMock,
...Object.values(mockStoreConfig.modules.diffs.actions),
].forEach(mock => mock.mockReset());
+
+ wrapper.destroy();
});
const findHeader = () => wrapper.find({ ref: 'header' });
@@ -70,7 +78,7 @@ describe('DiffFileHeader component', () => {
const findCollapseIcon = () => wrapper.find({ ref: 'collapseIcon' });
const findIconByName = iconName => {
- const icons = wrapper.findAll(Icon).filter(w => w.props('name') === iconName);
+ const icons = wrapper.findAll(GlIcon).filter(w => w.props('name') === iconName);
if (icons.length === 0) return icons;
if (icons.length > 1) {
throw new Error(`Multiple icons found for ${iconName}`);
@@ -79,8 +87,7 @@ describe('DiffFileHeader component', () => {
};
const createComponent = props => {
- const localVue = createLocalVue();
- localVue.use(Vuex);
+ mockStoreConfig = cloneDeep(defaultMockStoreConfig);
const store = new Vuex.Store(mockStoreConfig);
wrapper = shallowMount(DiffFileHeader, {
@@ -285,7 +292,7 @@ describe('DiffFileHeader component', () => {
findToggleDiscussionsButton().vm.$emit('click');
expect(
mockStoreConfig.modules.diffs.actions.toggleFileDiscussionWrappers,
- ).toHaveBeenCalledWith(expect.any(Object), diffFile, undefined);
+ ).toHaveBeenCalledWith(expect.any(Object), diffFile);
});
});
diff --git a/spec/frontend/diffs/components/diff_file_row_spec.js b/spec/frontend/diffs/components/diff_file_row_spec.js
index afdd4bfb335..23adc8f9da4 100644
--- a/spec/frontend/diffs/components/diff_file_row_spec.js
+++ b/spec/frontend/diffs/components/diff_file_row_spec.js
@@ -7,9 +7,12 @@ import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
describe('Diff File Row component', () => {
let wrapper;
- const createComponent = (props = {}) => {
+ const createComponent = (props = {}, highlightCurrentDiffRow = false) => {
wrapper = shallowMount(DiffFileRow, {
propsData: { ...props },
+ provide: {
+ glFeatures: { highlightCurrentDiffRow },
+ },
});
};
@@ -56,6 +59,31 @@ describe('Diff File Row component', () => {
);
});
+ it.each`
+ features | fileType | isViewed | expected
+ ${{ highlightCurrentDiffRow: true }} | ${'blob'} | ${false} | ${'gl-font-weight-bold'}
+ ${{}} | ${'blob'} | ${true} | ${''}
+ ${{}} | ${'tree'} | ${false} | ${''}
+ ${{}} | ${'tree'} | ${true} | ${''}
+ `(
+ 'with (features="$features", fileType="$fileType", isViewed=$isViewed), sets fileClasses="$expected"',
+ ({ features, fileType, isViewed, expected }) => {
+ createComponent(
+ {
+ file: {
+ type: fileType,
+ fileHash: '#123456789',
+ },
+ level: 0,
+ hideFileStats: false,
+ viewedFiles: isViewed ? { '#123456789': true } : {},
+ },
+ features.highlightCurrentDiffRow,
+ );
+ expect(wrapper.find(FileRow).props('fileClasses')).toBe(expected);
+ },
+ );
+
describe('FileRowStats components', () => {
it.each`
type | hideFileStats | value | desc
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index ead8bd79cdb..79f0f6bc327 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -45,7 +45,7 @@ describe('DiffFile', () => {
vm.$nextTick()
.then(() => {
- expect(el.querySelectorAll('.line_content').length).toBe(5);
+ expect(el.querySelectorAll('.line_content').length).toBe(8);
expect(el.querySelectorAll('.js-line-expansion-content').length).toBe(1);
triggerEvent('.btn-clipboard');
})
@@ -90,8 +90,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This diff is collapsed');
- expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
+ expect(vm.$el.innerText).toContain('This file is collapsed.');
+ expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
done();
});
@@ -102,8 +102,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This diff is collapsed');
- expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
+ expect(vm.$el.innerText).toContain('This file is collapsed.');
+ expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
done();
});
@@ -121,28 +121,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This diff is collapsed');
- expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
-
- done();
- });
- });
-
- it('should auto-expand collapsed files when viewDiffsFileByFile is true', done => {
- vm.$destroy();
- window.gon = {
- features: { autoExpandCollapsedDiffs: true },
- };
- vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), {
- file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)),
- canCurrentUserFork: false,
- viewDiffsFileByFile: true,
- }).$mount();
-
- vm.$nextTick(() => {
- expect(vm.$el.innerText).not.toContain('This diff is collapsed');
-
- window.gon = {};
+ expect(vm.$el.innerText).toContain('This file is collapsed.');
+ expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
done();
});
@@ -155,7 +135,7 @@ describe('DiffFile', () => {
vm.file.viewer.name = diffViewerModes.renamed;
vm.$nextTick(() => {
- expect(vm.$el.innerText).not.toContain('This diff is collapsed');
+ expect(vm.$el.innerText).not.toContain('This file is collapsed.');
done();
});
@@ -168,7 +148,7 @@ describe('DiffFile', () => {
vm.file.viewer.name = diffViewerModes.mode_changed;
vm.$nextTick(() => {
- expect(vm.$el.innerText).not.toContain('This diff is collapsed');
+ expect(vm.$el.innerText).not.toContain('This file is collapsed.');
done();
});
@@ -235,7 +215,7 @@ describe('DiffFile', () => {
it('calls handleLoadCollapsedDiff if collapsed changed & file has no lines', done => {
jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {});
- vm.file.highlighted_diff_lines = undefined;
+ vm.file.highlighted_diff_lines = [];
vm.file.parallel_diff_lines = [];
vm.isCollapsed = true;
@@ -262,8 +242,8 @@ describe('DiffFile', () => {
jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {});
- vm.file.highlighted_diff_lines = undefined;
- vm.file.parallel_diff_lines = [];
+ vm.file.highlighted_diff_lines = [];
+ vm.file.parallel_diff_lines = undefined;
vm.isCollapsed = true;
vm.$nextTick()
diff --git a/spec/frontend/diffs/components/diff_stats_spec.js b/spec/frontend/diffs/components/diff_stats_spec.js
index 7a083fb6bde..4dcbb3ec332 100644
--- a/spec/frontend/diffs/components/diff_stats_spec.js
+++ b/spec/frontend/diffs/components/diff_stats_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import DiffStats from '~/diffs/components/diff_stats.vue';
-import Icon from '~/vue_shared/components/icon.vue';
const TEST_ADDED_LINES = 100;
const TEST_REMOVED_LINES = 200;
@@ -53,7 +53,7 @@ describe('diff_stats', () => {
describe('files changes', () => {
const findIcon = name =>
wrapper
- .findAll(Icon)
+ .findAll(GlIcon)
.filter(c => c.attributes('name') === name)
.at(0).element.parentNode;
diff --git a/spec/frontend/diffs/components/image_diff_overlay_spec.js b/spec/frontend/diffs/components/image_diff_overlay_spec.js
index accf0a972d0..5a88a3cabd1 100644
--- a/spec/frontend/diffs/components/image_diff_overlay_spec.js
+++ b/spec/frontend/diffs/components/image_diff_overlay_spec.js
@@ -1,8 +1,8 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import { createStore } from '~/mr_notes/stores';
import { imageDiffDiscussions } from '../mock_data/diff_discussions';
-import Icon from '~/vue_shared/components/icon.vue';
describe('Diffs image diff overlay component', () => {
const dimensions = {
@@ -64,7 +64,7 @@ describe('Diffs image diff overlay component', () => {
it('renders icon when showCommentIcon is true', () => {
createComponent({ showCommentIcon: true });
- expect(wrapper.find(Icon).exists()).toBe(true);
+ expect(wrapper.find(GlIcon).exists()).toBe(true);
});
it('sets badge comment positions', () => {
diff --git a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js
index 90f012fbafe..81e5403d502 100644
--- a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js
@@ -5,12 +5,13 @@ import InlineDiffExpansionRow from '~/diffs/components/inline_diff_expansion_row
import diffFileMockData from '../mock_data/diff_file';
describe('InlineDiffExpansionRow', () => {
- const matchLine = diffFileMockData.highlighted_diff_lines[5];
+ const mockData = { ...diffFileMockData };
+ const matchLine = mockData.highlighted_diff_lines.pop();
const createComponent = (options = {}) => {
const cmp = Vue.extend(InlineDiffExpansionRow);
const defaults = {
- fileHash: diffFileMockData.file_hash,
+ fileHash: mockData.file_hash,
contextLinesPath: 'contextLinesPath',
line: matchLine,
isTop: false,
diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js
index f929f97b598..951b3f6258b 100644
--- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js
@@ -1,114 +1,317 @@
import { shallowMount } from '@vue/test-utils';
+import { TEST_HOST } from 'helpers/test_constants';
import { createStore } from '~/mr_notes/stores';
import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue';
+import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue';
import diffFileMockData from '../mock_data/diff_file';
+import discussionsMockData from '../mock_data/diff_discussions';
+
+const TEST_USER_ID = 'abc123';
+const TEST_USER = { id: TEST_USER_ID };
describe('InlineDiffTableRow', () => {
let wrapper;
- let vm;
+ let store;
const thisLine = diffFileMockData.highlighted_diff_lines[0];
- beforeEach(() => {
+ const createComponent = (props = {}, propsStore = store) => {
wrapper = shallowMount(InlineDiffTableRow, {
- store: createStore(),
+ store: propsStore,
propsData: {
line: thisLine,
fileHash: diffFileMockData.file_hash,
filePath: diffFileMockData.file_path,
contextLinesPath: 'contextLinesPath',
isHighlighted: false,
+ ...props,
},
});
- vm = wrapper.vm;
+ };
+
+ const setWindowLocation = value => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value,
+ });
+ };
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.notes.userData = TEST_USER;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
});
- it('does not add hll class to line content when line does not match highlighted row', done => {
- vm.$nextTick()
- .then(() => {
- expect(wrapper.find('.line_content').classes('hll')).toBe(false);
- })
- .then(done)
- .catch(done.fail);
+ it('does not add hll class to line content when line does not match highlighted row', () => {
+ createComponent();
+ expect(wrapper.find('.line_content').classes('hll')).toBe(false);
});
- it('adds hll class to lineContent when line is the highlighted row', done => {
- vm.$nextTick()
- .then(() => {
- vm.$store.state.diffs.highlightedRow = thisLine.line_code;
-
- return vm.$nextTick();
- })
- .then(() => {
- expect(wrapper.find('.line_content').classes('hll')).toBe(true);
- })
- .then(done)
- .catch(done.fail);
+ it('adds hll class to lineContent when line is the highlighted row', () => {
+ store.state.diffs.highlightedRow = thisLine.line_code;
+ createComponent({}, store);
+ expect(wrapper.find('.line_content').classes('hll')).toBe(true);
});
it('adds hll class to lineContent when line is part of a multiline comment', () => {
- wrapper.setProps({ isCommented: true });
- return vm.$nextTick().then(() => {
- expect(wrapper.find('.line_content').classes('hll')).toBe(true);
- });
+ createComponent({ isCommented: true });
+ expect(wrapper.find('.line_content').classes('hll')).toBe(true);
});
describe('sets coverage title and class', () => {
- it('for lines with coverage', done => {
- vm.$nextTick()
- .then(() => {
- const name = diffFileMockData.file_path;
- const line = thisLine.new_line;
-
- vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 5 } } };
-
- return vm.$nextTick();
- })
- .then(() => {
- const coverage = wrapper.find('.line-coverage');
-
- expect(coverage.attributes('title')).toContain('Test coverage: 5 hits');
- expect(coverage.classes('coverage')).toBe(true);
- })
- .then(done)
- .catch(done.fail);
+ it('for lines with coverage', () => {
+ const name = diffFileMockData.file_path;
+ const line = thisLine.new_line;
+
+ store.state.diffs.coverageFiles = { files: { [name]: { [line]: 5 } } };
+ createComponent({}, store);
+ const coverage = wrapper.find('.line-coverage');
+
+ expect(coverage.attributes('title')).toContain('Test coverage: 5 hits');
+ expect(coverage.classes('coverage')).toBe(true);
+ });
+
+ it('for lines without coverage', () => {
+ const name = diffFileMockData.file_path;
+ const line = thisLine.new_line;
+
+ store.state.diffs.coverageFiles = { files: { [name]: { [line]: 0 } } };
+ createComponent({}, store);
+ const coverage = wrapper.find('.line-coverage');
+
+ expect(coverage.attributes('title')).toContain('No test coverage');
+ expect(coverage.classes('no-coverage')).toBe(true);
+ });
+
+ it('for unknown lines', () => {
+ store.state.diffs.coverageFiles = {};
+ createComponent({}, store);
+
+ const coverage = wrapper.find('.line-coverage');
+
+ expect(coverage.attributes('title')).toBeUndefined();
+ expect(coverage.classes('coverage')).toBe(false);
+ expect(coverage.classes('no-coverage')).toBe(false);
+ });
+ });
+
+ describe('Table Cells', () => {
+ const findNewTd = () => wrapper.find({ ref: 'newTd' });
+ const findOldTd = () => wrapper.find({ ref: 'oldTd' });
+
+ describe('td', () => {
+ it('highlights when isHighlighted true', () => {
+ store.state.diffs.highlightedRow = thisLine.line_code;
+ createComponent({}, store);
+
+ expect(findNewTd().classes()).toContain('hll');
+ expect(findOldTd().classes()).toContain('hll');
+ });
+
+ it('does not highlight when isHighlighted false', () => {
+ createComponent();
+
+ expect(findNewTd().classes()).not.toContain('hll');
+ expect(findOldTd().classes()).not.toContain('hll');
+ });
+ });
+
+ describe('comment button', () => {
+ const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButton' });
+
+ it.each`
+ userData | query | mergeRefHeadComments | expectation
+ ${TEST_USER} | ${'diff_head=false'} | ${false} | ${true}
+ ${TEST_USER} | ${'diff_head=true'} | ${true} | ${true}
+ ${TEST_USER} | ${'diff_head=true'} | ${false} | ${false}
+ ${null} | ${''} | ${true} | ${false}
+ `(
+ 'exists is $expectation - with userData ($userData) query ($query)',
+ ({ userData, query, mergeRefHeadComments, expectation }) => {
+ store.state.notes.userData = userData;
+ gon.features = { mergeRefHeadComments };
+ setWindowLocation({ href: `${TEST_HOST}?${query}` });
+ createComponent({}, store);
+
+ expect(findNoteButton().exists()).toBe(expectation);
+ },
+ );
+
+ it.each`
+ isHover | line | expectation
+ ${true} | ${{ ...thisLine, discussions: [] }} | ${true}
+ ${false} | ${{ ...thisLine, discussions: [] }} | ${false}
+ ${true} | ${{ ...thisLine, type: 'context', discussions: [] }} | ${false}
+ ${true} | ${{ ...thisLine, type: 'old-nonewline', discussions: [] }} | ${false}
+ ${true} | ${{ ...thisLine, discussions: [{}] }} | ${false}
+ `('visible is $expectation - line ($line)', ({ isHover, line, expectation }) => {
+ createComponent({ line });
+ wrapper.setData({ isHover });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findNoteButton().isVisible()).toBe(expectation);
+ });
+ });
+
+ it.each`
+ disabled | commentsDisabled
+ ${'disabled'} | ${true}
+ ${undefined} | ${false}
+ `(
+ 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled',
+ ({ disabled, commentsDisabled }) => {
+ createComponent({
+ line: { ...thisLine, commentsDisabled },
+ });
+
+ wrapper.setData({ isHover: true });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findNoteButton().attributes('disabled')).toBe(disabled);
+ });
+ },
+ );
+
+ const symlinkishFileTooltip =
+ 'Commenting on symbolic links that replace or are replaced by files is currently not supported.';
+ const realishFileTooltip =
+ 'Commenting on files that replace or are replaced by symbolic links is currently not supported.';
+ const otherFileTooltip = 'Add a comment to this line';
+ const findTooltip = () => wrapper.find({ ref: 'addNoteTooltip' });
+
+ it.each`
+ tooltip | commentsDisabled
+ ${symlinkishFileTooltip} | ${{ wasSymbolic: true }}
+ ${symlinkishFileTooltip} | ${{ isSymbolic: true }}
+ ${realishFileTooltip} | ${{ wasReal: true }}
+ ${realishFileTooltip} | ${{ isReal: true }}
+ ${otherFileTooltip} | ${false}
+ `(
+ 'has the correct tooltip when commentsDisabled=$commentsDisabled',
+ ({ tooltip, commentsDisabled }) => {
+ createComponent({
+ line: { ...thisLine, commentsDisabled },
+ });
+
+ wrapper.setData({ isHover: true });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findTooltip().attributes('title')).toBe(tooltip);
+ });
+ },
+ );
});
- it('for lines without coverage', done => {
- vm.$nextTick()
- .then(() => {
- const name = diffFileMockData.file_path;
- const line = thisLine.new_line;
+ describe('line number', () => {
+ const findLineNumberOld = () => wrapper.find({ ref: 'lineNumberRefOld' });
+ const findLineNumberNew = () => wrapper.find({ ref: 'lineNumberRefNew' });
+
+ it('renders line numbers in correct cells', () => {
+ createComponent();
+
+ expect(findLineNumberOld().exists()).toBe(false);
+ expect(findLineNumberNew().exists()).toBe(true);
+ });
- vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 0 } } };
+ describe('with lineNumber prop', () => {
+ const TEST_LINE_CODE = 'LC_42';
+ const TEST_LINE_NUMBER = 1;
- return vm.$nextTick();
- })
- .then(() => {
- const coverage = wrapper.find('.line-coverage');
+ describe.each`
+ lineProps | findLineNumber | expectedHref | expectedClickArg
+ ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE}
+ ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined}
+ ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE }, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${TEST_LINE_CODE}
+ ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE }, new_line: TEST_LINE_NUMBER }} | ${findLineNumberNew} | ${'#'} | ${TEST_LINE_CODE}
+ `(
+ 'with line ($lineProps)',
+ ({ lineProps, findLineNumber, expectedHref, expectedClickArg }) => {
+ beforeEach(() => {
+ jest.spyOn(store, 'dispatch').mockImplementation();
+ createComponent({
+ line: { ...thisLine, ...lineProps },
+ });
+ });
- expect(coverage.attributes('title')).toContain('No test coverage');
- expect(coverage.classes('no-coverage')).toBe(true);
- })
- .then(done)
- .catch(done.fail);
+ it('renders', () => {
+ expect(findLineNumber().exists()).toBe(true);
+ expect(findLineNumber().attributes()).toEqual({
+ href: expectedHref,
+ 'data-linenumber': TEST_LINE_NUMBER.toString(),
+ });
+ });
+
+ it('on click, dispatches setHighlightedRow', () => {
+ expect(store.dispatch).toHaveBeenCalledTimes(1);
+
+ findLineNumber().trigger('click');
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'diffs/setHighlightedRow',
+ expectedClickArg,
+ );
+ expect(store.dispatch).toHaveBeenCalledTimes(2);
+ });
+ },
+ );
+ });
});
- it('for unknown lines', done => {
- vm.$nextTick()
- .then(() => {
- vm.$store.state.diffs.coverageFiles = {};
-
- return vm.$nextTick();
- })
- .then(() => {
- const coverage = wrapper.find('.line-coverage');
-
- expect(coverage.attributes('title')).toBeUndefined();
- expect(coverage.classes('coverage')).toBe(false);
- expect(coverage.classes('no-coverage')).toBe(false);
- })
- .then(done)
- .catch(done.fail);
+ describe('diff-gutter-avatars', () => {
+ const TEST_LINE_CODE = 'LC_42';
+ const TEST_FILE_HASH = diffFileMockData.file_hash;
+ const findAvatars = () => wrapper.find(DiffGutterAvatars);
+ let line;
+
+ beforeEach(() => {
+ jest.spyOn(store, 'dispatch').mockImplementation();
+
+ line = {
+ line_code: TEST_LINE_CODE,
+ type: 'new',
+ old_line: null,
+ new_line: 1,
+ discussions: [{ ...discussionsMockData }],
+ discussionsExpanded: true,
+ text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
+ rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
+ meta_data: null,
+ };
+ });
+
+ describe('with showCommentButton', () => {
+ it('renders if line has discussions', () => {
+ createComponent({ line });
+
+ expect(findAvatars().props()).toEqual({
+ discussions: line.discussions,
+ discussionsExpanded: line.discussionsExpanded,
+ });
+ });
+
+ it('does notrender if line has no discussions', () => {
+ line.discussions = [];
+ createComponent({ line });
+
+ expect(findAvatars().exists()).toEqual(false);
+ });
+
+ it('toggles line discussion', () => {
+ createComponent({ line });
+
+ expect(store.dispatch).toHaveBeenCalledTimes(1);
+
+ findAvatars().vm.$emit('toggleLineDiscussions');
+
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleLineDiscussions', {
+ lineCode: TEST_LINE_CODE,
+ fileHash: TEST_FILE_HASH,
+ expanded: !line.discussionsExpanded,
+ });
+ });
+ });
});
});
});
diff --git a/spec/frontend/diffs/components/inline_diff_view_spec.js b/spec/frontend/diffs/components/inline_diff_view_spec.js
index 6c37f86658e..39c581e2796 100644
--- a/spec/frontend/diffs/components/inline_diff_view_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_view_spec.js
@@ -30,8 +30,8 @@ describe('InlineDiffView', () => {
it('should have rendered diff lines', () => {
const el = component.$el;
- expect(el.querySelectorAll('tr.line_holder').length).toEqual(5);
- expect(el.querySelectorAll('tr.line_holder.new').length).toEqual(2);
+ expect(el.querySelectorAll('tr.line_holder').length).toEqual(8);
+ expect(el.querySelectorAll('tr.line_holder.new').length).toEqual(4);
expect(el.querySelectorAll('tr.line_expansion.match').length).toEqual(1);
expect(el.textContent.indexOf('Bad dates')).toBeGreaterThan(-1);
});
diff --git a/spec/frontend/diffs/components/merge_conflict_warning_spec.js b/spec/frontend/diffs/components/merge_conflict_warning_spec.js
new file mode 100644
index 00000000000..2f303f25f66
--- /dev/null
+++ b/spec/frontend/diffs/components/merge_conflict_warning_spec.js
@@ -0,0 +1,77 @@
+import { shallowMount, mount } from '@vue/test-utils';
+import MergeConflictWarning from '~/diffs/components/merge_conflict_warning.vue';
+import { CENTERED_LIMITED_CONTAINER_CLASSES } from '~/diffs/constants';
+
+const propsData = {
+ limited: true,
+ mergeable: true,
+ resolutionPath: 'a-path',
+};
+const limitedClasses = CENTERED_LIMITED_CONTAINER_CLASSES.split(' ');
+
+function findResolveButton(wrapper) {
+ return wrapper.find('.gl-alert-actions a.gl-button:first-child');
+}
+function findLocalMergeButton(wrapper) {
+ return wrapper.find('.gl-alert-actions button.gl-button:last-child');
+}
+
+describe('MergeConflictWarning', () => {
+ let wrapper;
+
+ const createComponent = (props = {}, { full } = { full: false }) => {
+ const mounter = full ? mount : shallowMount;
+
+ wrapper = mounter(MergeConflictWarning, {
+ propsData: { ...propsData, ...props },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ limited | containerClasses
+ ${true} | ${limitedClasses}
+ ${false} | ${[]}
+ `(
+ 'has the correct container classes when limited is $limited',
+ ({ limited, containerClasses }) => {
+ createComponent({ limited });
+
+ expect(wrapper.classes()).toEqual(containerClasses);
+ },
+ );
+
+ it.each`
+ present | resolutionPath
+ ${false} | ${''}
+ ${true} | ${'some-path'}
+ `(
+ 'toggles the resolve conflicts button based on the provided resolutionPath "$resolutionPath"',
+ ({ present, resolutionPath }) => {
+ createComponent({ resolutionPath }, { full: true });
+ const resolveButton = findResolveButton(wrapper);
+
+ expect(resolveButton.exists()).toBe(present);
+ if (present) {
+ expect(resolveButton.attributes('href')).toBe(resolutionPath);
+ }
+ },
+ );
+
+ it.each`
+ present | mergeable
+ ${false} | ${false}
+ ${true} | ${true}
+ `(
+ 'toggles the local merge button based on the provided mergeable property "$mergable"',
+ ({ present, mergeable }) => {
+ createComponent({ mergeable }, { full: true });
+ const localMerge = findLocalMergeButton(wrapper);
+
+ expect(localMerge.exists()).toBe(present);
+ },
+ );
+});
diff --git a/spec/frontend/diffs/components/no_changes_spec.js b/spec/frontend/diffs/components/no_changes_spec.js
index 2795c68b4ee..78805a1cddc 100644
--- a/spec/frontend/diffs/components/no_changes_spec.js
+++ b/spec/frontend/diffs/components/no_changes_spec.js
@@ -36,7 +36,7 @@ describe('Diff no changes empty state', () => {
};
});
- expect(vm.contains('script')).toBe(false);
+ expect(vm.find('script').exists()).toBe(false);
});
describe('Renders', () => {
diff --git a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js
index 339352943a9..13c4ce06f18 100644
--- a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js
+++ b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js
@@ -1,9 +1,12 @@
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { TEST_HOST } from 'helpers/test_constants';
import { createStore } from '~/mr_notes/stores';
import ParallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue';
import diffFileMockData from '../mock_data/diff_file';
+import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue';
+import discussionsMockData from '../mock_data/diff_discussions';
describe('ParallelDiffTableRow', () => {
describe('when one side is empty', () => {
@@ -158,4 +161,260 @@ describe('ParallelDiffTableRow', () => {
});
});
});
+
+ describe('Table Cells', () => {
+ let wrapper;
+ let store;
+ let thisLine;
+ const TEST_USER_ID = 'abc123';
+ const TEST_USER = { id: TEST_USER_ID };
+
+ const createComponent = (props = {}, propsStore = store, data = {}) => {
+ wrapper = shallowMount(ParallelDiffTableRow, {
+ store: propsStore,
+ propsData: {
+ line: thisLine,
+ fileHash: diffFileMockData.file_hash,
+ filePath: diffFileMockData.file_path,
+ contextLinesPath: 'contextLinesPath',
+ isHighlighted: false,
+ ...props,
+ },
+ data() {
+ return data;
+ },
+ });
+ };
+
+ const setWindowLocation = value => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value,
+ });
+ };
+
+ beforeEach(() => {
+ // eslint-disable-next-line prefer-destructuring
+ thisLine = diffFileMockData.parallel_diff_lines[2];
+ store = createStore();
+ store.state.notes.userData = TEST_USER;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findNewTd = () => wrapper.find({ ref: 'newTd' });
+ const findOldTd = () => wrapper.find({ ref: 'oldTd' });
+
+ describe('td', () => {
+ it('highlights when isHighlighted true', () => {
+ store.state.diffs.highlightedRow = thisLine.left.line_code;
+ createComponent({}, store);
+
+ expect(findNewTd().classes()).toContain('hll');
+ expect(findOldTd().classes()).toContain('hll');
+ });
+
+ it('does not highlight when isHighlighted false', () => {
+ createComponent();
+
+ expect(findNewTd().classes()).not.toContain('hll');
+ expect(findOldTd().classes()).not.toContain('hll');
+ });
+ });
+
+ describe('comment button', () => {
+ const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButtonLeft' });
+
+ it.each`
+ hover | line | userData | query | mergeRefHeadComments | expectation
+ ${true} | ${{}} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${true}
+ ${true} | ${{ line: { left: null } }} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${false}
+ ${true} | ${{}} | ${TEST_USER} | ${'diff_head=true'} | ${true} | ${true}
+ ${true} | ${{}} | ${TEST_USER} | ${'diff_head=true'} | ${false} | ${false}
+ ${true} | ${{}} | ${null} | ${''} | ${true} | ${false}
+ ${false} | ${{}} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${false}
+ `(
+ 'exists is $expectation - with userData ($userData) query ($query)',
+ async ({ hover, line, userData, query, mergeRefHeadComments, expectation }) => {
+ store.state.notes.userData = userData;
+ gon.features = { mergeRefHeadComments };
+ setWindowLocation({ href: `${TEST_HOST}?${query}` });
+ createComponent(line, store);
+ if (hover) await wrapper.find('.line_holder').trigger('mouseover');
+
+ expect(findNoteButton().exists()).toBe(expectation);
+ },
+ );
+
+ it.each`
+ line | expectation
+ ${{ ...thisLine, left: { discussions: [] } }} | ${true}
+ ${{ ...thisLine, left: { type: 'context', discussions: [] } }} | ${false}
+ ${{ ...thisLine, left: { type: 'old-nonewline', discussions: [] } }} | ${false}
+ ${{ ...thisLine, left: { discussions: [{}] } }} | ${false}
+ `('visible is $expectation - line ($line)', async ({ line, expectation }) => {
+ createComponent({ line }, store, { isLeftHover: true, isCommentButtonRendered: true });
+
+ expect(findNoteButton().isVisible()).toBe(expectation);
+ });
+
+ it.each`
+ disabled | commentsDisabled
+ ${'disabled'} | ${true}
+ ${undefined} | ${false}
+ `(
+ 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled',
+ ({ disabled, commentsDisabled }) => {
+ thisLine.left.commentsDisabled = commentsDisabled;
+ createComponent({ line: { ...thisLine } }, store, {
+ isLeftHover: true,
+ isCommentButtonRendered: true,
+ });
+
+ expect(findNoteButton().attributes('disabled')).toBe(disabled);
+ },
+ );
+
+ const symlinkishFileTooltip =
+ 'Commenting on symbolic links that replace or are replaced by files is currently not supported.';
+ const realishFileTooltip =
+ 'Commenting on files that replace or are replaced by symbolic links is currently not supported.';
+ const otherFileTooltip = 'Add a comment to this line';
+ const findTooltip = () => wrapper.find({ ref: 'addNoteTooltipLeft' });
+
+ it.each`
+ tooltip | commentsDisabled
+ ${symlinkishFileTooltip} | ${{ wasSymbolic: true }}
+ ${symlinkishFileTooltip} | ${{ isSymbolic: true }}
+ ${realishFileTooltip} | ${{ wasReal: true }}
+ ${realishFileTooltip} | ${{ isReal: true }}
+ ${otherFileTooltip} | ${false}
+ `(
+ 'has the correct tooltip when commentsDisabled=$commentsDisabled',
+ ({ tooltip, commentsDisabled }) => {
+ thisLine.left.commentsDisabled = commentsDisabled;
+ createComponent({ line: { ...thisLine } }, store, {
+ isLeftHover: true,
+ isCommentButtonRendered: true,
+ });
+
+ expect(findTooltip().attributes('title')).toBe(tooltip);
+ },
+ );
+ });
+
+ describe('line number', () => {
+ const findLineNumberOld = () => wrapper.find({ ref: 'lineNumberRefOld' });
+ const findLineNumberNew = () => wrapper.find({ ref: 'lineNumberRefNew' });
+
+ it('renders line numbers in correct cells', () => {
+ createComponent();
+
+ expect(findLineNumberOld().exists()).toBe(true);
+ expect(findLineNumberNew().exists()).toBe(true);
+ });
+
+ describe('with lineNumber prop', () => {
+ const TEST_LINE_CODE = 'LC_42';
+ const TEST_LINE_NUMBER = 1;
+
+ describe.each`
+ lineProps | findLineNumber | expectedHref | expectedClickArg
+ ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE}
+ ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined}
+ `(
+ 'with line ($lineProps)',
+ ({ lineProps, findLineNumber, expectedHref, expectedClickArg }) => {
+ beforeEach(() => {
+ jest.spyOn(store, 'dispatch').mockImplementation();
+ Object.assign(thisLine.left, lineProps);
+ Object.assign(thisLine.right, lineProps);
+ createComponent({
+ line: { ...thisLine },
+ });
+ });
+
+ it('renders', () => {
+ expect(findLineNumber().exists()).toBe(true);
+ expect(findLineNumber().attributes()).toEqual({
+ href: expectedHref,
+ 'data-linenumber': TEST_LINE_NUMBER.toString(),
+ });
+ });
+
+ it('on click, dispatches setHighlightedRow', () => {
+ expect(store.dispatch).toHaveBeenCalledTimes(1);
+
+ findLineNumber().trigger('click');
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'diffs/setHighlightedRow',
+ expectedClickArg,
+ );
+ expect(store.dispatch).toHaveBeenCalledTimes(2);
+ });
+ },
+ );
+ });
+ });
+
+ describe('diff-gutter-avatars', () => {
+ const TEST_LINE_CODE = 'LC_42';
+ const TEST_FILE_HASH = diffFileMockData.file_hash;
+ const findAvatars = () => wrapper.find(DiffGutterAvatars);
+ let line;
+
+ beforeEach(() => {
+ jest.spyOn(store, 'dispatch').mockImplementation();
+
+ line = {
+ left: {
+ line_code: TEST_LINE_CODE,
+ type: 'new',
+ old_line: null,
+ new_line: 1,
+ discussions: [{ ...discussionsMockData }],
+ discussionsExpanded: true,
+ text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
+ rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
+ meta_data: null,
+ },
+ };
+ });
+
+ describe('with showCommentButton', () => {
+ it('renders if line has discussions', () => {
+ createComponent({ line });
+
+ expect(findAvatars().props()).toEqual({
+ discussions: line.left.discussions,
+ discussionsExpanded: line.left.discussionsExpanded,
+ });
+ });
+
+ it('does notrender if line has no discussions', () => {
+ line.left.discussions = [];
+ createComponent({ line });
+
+ expect(findAvatars().exists()).toEqual(false);
+ });
+
+ it('toggles line discussion', () => {
+ createComponent({ line });
+
+ expect(store.dispatch).toHaveBeenCalledTimes(1);
+
+ findAvatars().vm.$emit('toggleLineDiscussions');
+
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleLineDiscussions', {
+ lineCode: TEST_LINE_CODE,
+ fileHash: TEST_FILE_HASH,
+ expanded: !line.left.discussionsExpanded,
+ });
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/parallel_diff_view_spec.js b/spec/frontend/diffs/components/parallel_diff_view_spec.js
index cb1a47f60d5..44ed303d0ef 100644
--- a/spec/frontend/diffs/components/parallel_diff_view_spec.js
+++ b/spec/frontend/diffs/components/parallel_diff_view_spec.js
@@ -1,33 +1,37 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
import { createStore } from '~/mr_notes/stores';
import ParallelDiffView from '~/diffs/components/parallel_diff_view.vue';
-import * as constants from '~/diffs/constants';
+import parallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue';
import diffFileMockData from '../mock_data/diff_file';
-describe('ParallelDiffView', () => {
- let component;
- const getDiffFileMock = () => ({ ...diffFileMockData });
+let wrapper;
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
- beforeEach(() => {
- const diffFile = getDiffFileMock();
+function factory() {
+ const diffFile = { ...diffFileMockData };
+ const store = createStore();
- component = createComponentWithStore(Vue.extend(ParallelDiffView), createStore(), {
+ wrapper = shallowMount(ParallelDiffView, {
+ localVue,
+ store,
+ propsData: {
diffFile,
diffLines: diffFile.parallel_diff_lines,
- }).$mount();
+ },
});
+}
+describe('ParallelDiffView', () => {
afterEach(() => {
- component.$destroy();
+ wrapper.destroy();
});
- describe('assigned', () => {
- describe('diffLines', () => {
- it('should normalize lines for empty cells', () => {
- expect(component.diffLines[0].left.type).toEqual(constants.EMPTY_CELL_TYPE);
- expect(component.diffLines[1].left.type).toEqual(constants.EMPTY_CELL_TYPE);
- });
- });
+ it('renders diff lines', () => {
+ factory();
+
+ expect(wrapper.findAll(parallelDiffTableRow).length).toBe(8);
});
});
diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js
index 2e95d79ea49..72330d8efba 100644
--- a/spec/frontend/diffs/components/settings_dropdown_spec.js
+++ b/spec/frontend/diffs/components/settings_dropdown_spec.js
@@ -7,7 +7,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constant
const localVue = createLocalVue();
localVue.use(Vuex);
-describe('Diff settiings dropdown component', () => {
+describe('Diff settings dropdown component', () => {
let vm;
let actions;
@@ -50,7 +50,7 @@ describe('Diff settiings dropdown component', () => {
vm.find('.js-list-view').trigger('click');
- expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false, undefined);
+ expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false);
});
it('tree view button dispatches setRenderTreeList with true', () => {
@@ -58,53 +58,53 @@ describe('Diff settiings dropdown component', () => {
vm.find('.js-tree-view').trigger('click');
- expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true, undefined);
+ expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true);
});
- it('sets list button as active when renderTreeList is false', () => {
+ it('sets list button as selected when renderTreeList is false', () => {
createComponent(store => {
Object.assign(store.state.diffs, {
renderTreeList: false,
});
});
- expect(vm.find('.js-list-view').classes('active')).toBe(true);
- expect(vm.find('.js-tree-view').classes('active')).toBe(false);
+ expect(vm.find('.js-list-view').classes('selected')).toBe(true);
+ expect(vm.find('.js-tree-view').classes('selected')).toBe(false);
});
- it('sets tree button as active when renderTreeList is true', () => {
+ it('sets tree button as selected when renderTreeList is true', () => {
createComponent(store => {
Object.assign(store.state.diffs, {
renderTreeList: true,
});
});
- expect(vm.find('.js-list-view').classes('active')).toBe(false);
- expect(vm.find('.js-tree-view').classes('active')).toBe(true);
+ expect(vm.find('.js-list-view').classes('selected')).toBe(false);
+ expect(vm.find('.js-tree-view').classes('selected')).toBe(true);
});
});
describe('compare changes', () => {
- it('sets inline button as active', () => {
+ it('sets inline button as selected', () => {
createComponent(store => {
Object.assign(store.state.diffs, {
diffViewType: INLINE_DIFF_VIEW_TYPE,
});
});
- expect(vm.find('.js-inline-diff-button').classes('active')).toBe(true);
- expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(false);
+ expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(true);
+ expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(false);
});
- it('sets parallel button as active', () => {
+ it('sets parallel button as selected', () => {
createComponent(store => {
Object.assign(store.state.diffs, {
diffViewType: PARALLEL_DIFF_VIEW_TYPE,
});
});
- expect(vm.find('.js-inline-diff-button').classes('active')).toBe(false);
- expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(true);
+ expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(false);
+ expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(true);
});
it('calls setInlineDiffViewType when clicking inline button', () => {
@@ -153,14 +153,10 @@ describe('Diff settiings dropdown component', () => {
checkbox.element.checked = true;
checkbox.trigger('change');
- expect(actions.setShowWhitespace).toHaveBeenCalledWith(
- expect.anything(),
- {
- showWhitespace: true,
- pushState: true,
- },
- undefined,
- );
+ expect(actions.setShowWhitespace).toHaveBeenCalledWith(expect.anything(), {
+ showWhitespace: true,
+ pushState: true,
+ });
});
});
});
diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js
index 14cb2a17aec..cc177a81d88 100644
--- a/spec/frontend/diffs/components/tree_list_spec.js
+++ b/spec/frontend/diffs/components/tree_list_spec.js
@@ -1,16 +1,26 @@
import Vuex from 'vuex';
-import { mount, createLocalVue } from '@vue/test-utils';
+import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import TreeList from '~/diffs/components/tree_list.vue';
import createStore from '~/diffs/store/modules';
+import FileTree from '~/vue_shared/components/file_tree.vue';
describe('Diffs tree list component', () => {
let wrapper;
+ let store;
const getFileRows = () => wrapper.findAll('.file-row');
const localVue = createLocalVue();
localVue.use(Vuex);
- const createComponent = state => {
- const store = new Vuex.Store({
+ const createComponent = (mountFn = mount) => {
+ wrapper = mountFn(TreeList, {
+ store,
+ localVue,
+ propsData: { hideFileStats: false },
+ });
+ };
+
+ beforeEach(() => {
+ store = new Vuex.Store({
modules: {
diffs: createStore(),
},
@@ -23,61 +33,57 @@ describe('Diffs tree list component', () => {
addedLines: 10,
removedLines: 20,
...store.state.diffs,
- ...state,
};
+ });
- wrapper = mount(TreeList, {
- store,
- localVue,
- propsData: { hideFileStats: false },
+ const setupFilesInState = () => {
+ const treeEntries = {
+ 'index.js': {
+ addedLines: 0,
+ changed: true,
+ deleted: false,
+ fileHash: 'test',
+ key: 'index.js',
+ name: 'index.js',
+ path: 'app/index.js',
+ removedLines: 0,
+ tempFile: true,
+ type: 'blob',
+ parentPath: 'app',
+ },
+ app: {
+ key: 'app',
+ path: 'app',
+ name: 'app',
+ type: 'tree',
+ tree: [],
+ },
+ };
+
+ Object.assign(store.state.diffs, {
+ treeEntries,
+ tree: [treeEntries['index.js'], treeEntries.app],
});
};
- beforeEach(() => {
- localStorage.removeItem('mr_diff_tree_list');
-
- createComponent();
- });
-
afterEach(() => {
wrapper.destroy();
});
- it('renders empty text', () => {
- expect(wrapper.text()).toContain('No files found');
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders empty text', () => {
+ expect(wrapper.text()).toContain('No files found');
+ });
});
describe('with files', () => {
beforeEach(() => {
- const treeEntries = {
- 'index.js': {
- addedLines: 0,
- changed: true,
- deleted: false,
- fileHash: 'test',
- key: 'index.js',
- name: 'index.js',
- path: 'app/index.js',
- removedLines: 0,
- tempFile: true,
- type: 'blob',
- parentPath: 'app',
- },
- app: {
- key: 'app',
- path: 'app',
- name: 'app',
- type: 'tree',
- tree: [],
- },
- };
-
- createComponent({
- treeEntries,
- tree: [treeEntries['index.js'], treeEntries.app],
- });
-
- return wrapper.vm.$nextTick();
+ setupFilesInState();
+ createComponent();
});
it('renders tree', () => {
@@ -136,4 +142,23 @@ describe('Diffs tree list component', () => {
});
});
});
+
+ describe('with viewedDiffFileIds', () => {
+ const viewedDiffFileIds = { fileId: '#12345' };
+
+ beforeEach(() => {
+ setupFilesInState();
+ store.state.diffs.viewedDiffFileIds = viewedDiffFileIds;
+ });
+
+ it('passes the viewedDiffFileIds to the FileTree', () => {
+ createComponent(shallowMount);
+
+ return wrapper.vm.$nextTick().then(() => {
+ // Have to use $attrs['viewed-files'] because we are passing down an object
+ // and attributes('') stringifies values (e.g. [object])...
+ expect(wrapper.find(FileTree).vm.$attrs['viewed-files']).toBe(viewedDiffFileIds);
+ });
+ });
+ });
});
diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js
index e4b2fdf6ede..c2a4424ee95 100644
--- a/spec/frontend/diffs/mock_data/diff_file.js
+++ b/spec/frontend/diffs/mock_data/diff_file.js
@@ -56,8 +56,8 @@ export default {
old_line: null,
new_line: 1,
discussions: [],
- text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
- rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
+ text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
+ rich_text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
meta_data: null,
},
{
@@ -66,8 +66,8 @@ export default {
old_line: null,
new_line: 2,
discussions: [],
- text: '+<span id="LC2" class="line" lang="plaintext"></span>\n',
- rich_text: '+<span id="LC2" class="line" lang="plaintext"></span>\n',
+ text: '<span id="LC2" class="line" lang="plaintext"></span>\n',
+ rich_text: '<span id="LC2" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
{
@@ -76,8 +76,8 @@ export default {
old_line: 1,
new_line: 3,
discussions: [],
- text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
- rich_text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
+ text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
+ rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
meta_data: null,
},
{
@@ -86,8 +86,8 @@ export default {
old_line: 2,
new_line: 4,
discussions: [],
- text: ' <span id="LC4" class="line" lang="plaintext"></span>\n',
- rich_text: ' <span id="LC4" class="line" lang="plaintext"></span>\n',
+ text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
+ rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
{
@@ -96,8 +96,38 @@ export default {
old_line: 3,
new_line: 5,
discussions: [],
- text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
- rich_text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
+ text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
+ rich_text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
+ meta_data: null,
+ },
+ {
+ line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_6',
+ type: 'old',
+ old_line: 4,
+ new_line: null,
+ discussions: [],
+ text: '<span id="LC6" class="line" lang="plaintext"></span>\n',
+ rich_text: '<span id="LC6" class="line" lang="plaintext"></span>\n',
+ meta_data: null,
+ },
+ {
+ line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_7',
+ type: 'new',
+ old_line: null,
+ new_line: 5,
+ discussions: [],
+ text: '<span id="LC7" class="line" lang="plaintext"></span>\n',
+ rich_text: '<span id="LC7" class="line" lang="plaintext"></span>\n',
+ meta_data: null,
+ },
+ {
+ line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_9',
+ type: 'new',
+ old_line: null,
+ new_line: 6,
+ discussions: [],
+ text: '<span id="LC7" class="line" lang="plaintext"></span>\n',
+ rich_text: '<span id="LC7" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
{
@@ -116,43 +146,39 @@ export default {
],
parallel_diff_lines: [
{
- left: {
- type: 'empty-cell',
- },
+ left: null,
right: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1',
type: 'new',
old_line: null,
new_line: 1,
discussions: [],
- text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
+ text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
rich_text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
meta_data: null,
},
},
{
- left: {
- type: 'empty-cell',
- },
+ left: null,
right: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2',
type: 'new',
old_line: null,
new_line: 2,
discussions: [],
- text: '+<span id="LC2" class="line" lang="plaintext"></span>\n',
+ text: '<span id="LC2" class="line" lang="plaintext"></span>\n',
rich_text: '<span id="LC2" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
},
{
left: {
- line_Code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3',
+ line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3',
type: null,
old_line: 1,
new_line: 3,
discussions: [],
- text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
+ text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
meta_data: null,
},
@@ -162,7 +188,7 @@ export default {
old_line: 1,
new_line: 3,
discussions: [],
- text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
+ text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
meta_data: null,
},
@@ -174,7 +200,7 @@ export default {
old_line: 2,
new_line: 4,
discussions: [],
- text: ' <span id="LC4" class="line" lang="plaintext"></span>\n',
+ text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
@@ -184,7 +210,7 @@ export default {
old_line: 2,
new_line: 4,
discussions: [],
- text: ' <span id="LC4" class="line" lang="plaintext"></span>\n',
+ text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
@@ -196,7 +222,7 @@ export default {
old_line: 3,
new_line: 5,
discussions: [],
- text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
+ text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
rich_text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
meta_data: null,
},
@@ -206,13 +232,48 @@ export default {
old_line: 3,
new_line: 5,
discussions: [],
- text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
+ text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
rich_text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
meta_data: null,
},
},
{
left: {
+ line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_6',
+ type: 'old',
+ old_line: 4,
+ new_line: null,
+ discussions: [],
+ text: '<span id="LC6" class="line" lang="plaintext"></span>\n',
+ rich_text: '<span id="LC6" class="line" lang="plaintext"></span>\n',
+ meta_data: null,
+ },
+ right: {
+ line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_7',
+ type: 'new',
+ old_line: null,
+ new_line: 5,
+ discussions: [],
+ text: '<span id="LC7" class="line" lang="plaintext"></span>\n',
+ rich_text: '<span id="LC7" class="line" lang="plaintext"></span>\n',
+ meta_data: null,
+ },
+ },
+ {
+ left: null,
+ right: {
+ line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_9',
+ type: 'new',
+ old_line: null,
+ new_line: 6,
+ discussions: [],
+ text: '<span id="LC7" class="line" lang="plaintext"></span>\n',
+ rich_text: '<span id="LC7" class="line" lang="plaintext"></span>\n',
+ meta_data: null,
+ },
+ },
+ {
+ left: {
line_code: null,
type: 'match',
old_line: null,
diff --git a/spec/frontend/diffs/mock_data/diff_metadata.js b/spec/frontend/diffs/mock_data/diff_metadata.js
index b73b29e4bc8..cfa0038c06f 100644
--- a/spec/frontend/diffs/mock_data/diff_metadata.js
+++ b/spec/frontend/diffs/mock_data/diff_metadata.js
@@ -1,6 +1,3 @@
-/* eslint-disable import/prefer-default-export */
-/* https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20 */
-
export const diffMetadata = {
real_size: '1',
size: 1,
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index 5fef35d6c5b..4f647b0cd41 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -13,7 +13,6 @@ import {
} from '~/diffs/constants';
import {
setBaseConfig,
- fetchDiffFiles,
fetchDiffFilesBatch,
fetchDiffFilesMeta,
fetchCoverageFiles,
@@ -101,7 +100,6 @@ describe('DiffsStoreActions', () => {
const projectPath = '/root/project';
const dismissEndpoint = '/-/user_callouts';
const showSuggestPopover = false;
- const useSingleDiffStyle = false;
testAction(
setBaseConfig,
@@ -113,7 +111,6 @@ describe('DiffsStoreActions', () => {
projectPath,
dismissEndpoint,
showSuggestPopover,
- useSingleDiffStyle,
},
{
endpoint: '',
@@ -123,7 +120,6 @@ describe('DiffsStoreActions', () => {
projectPath: '',
dismissEndpoint: '',
showSuggestPopover: true,
- useSingleDiffStyle: true,
},
[
{
@@ -136,7 +132,6 @@ describe('DiffsStoreActions', () => {
projectPath,
dismissEndpoint,
showSuggestPopover,
- useSingleDiffStyle,
},
},
],
@@ -146,39 +141,6 @@ describe('DiffsStoreActions', () => {
});
});
- describe('fetchDiffFiles', () => {
- it('should fetch diff files', done => {
- const endpoint = '/fetch/diff/files?view=inline&w=1';
- const mock = new MockAdapter(axios);
- const res = { diff_files: 1, merge_request_diffs: [] };
- mock.onGet(endpoint).reply(200, res);
-
- testAction(
- fetchDiffFiles,
- {},
- { endpoint, diffFiles: [], showWhitespace: false, diffViewType: 'inline' },
- [
- { type: types.SET_LOADING, payload: true },
- { type: types.SET_LOADING, payload: false },
- { type: types.SET_MERGE_REQUEST_DIFFS, payload: res.merge_request_diffs },
- { type: types.SET_DIFF_DATA, payload: res },
- ],
- [],
- () => {
- mock.restore();
- done();
- },
- );
-
- fetchDiffFiles({ state: { endpoint }, commit: () => null })
- .then(data => {
- expect(data).toEqual(res);
- done();
- })
- .catch(done.fail);
- });
- });
-
describe('fetchDiffFilesBatch', () => {
let mock;
@@ -223,16 +185,16 @@ describe('DiffsStoreActions', () => {
testAction(
fetchDiffFilesBatch,
{},
- { endpointBatch, useSingleDiffStyle: true, diffViewType: 'inline' },
+ { endpointBatch, diffViewType: 'inline' },
[
{ type: types.SET_BATCH_LOADING, payload: true },
{ type: types.SET_RETRIEVING_BATCHES, payload: true },
{ type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } },
{ type: types.SET_BATCH_LOADING, payload: false },
- { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test' },
+ { type: types.VIEW_DIFF_FILE, payload: 'test' },
{ type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } },
{ type: types.SET_BATCH_LOADING, payload: false },
- { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test2' },
+ { type: types.VIEW_DIFF_FILE, payload: 'test2' },
{ type: types.SET_RETRIEVING_BATCHES, payload: false },
],
[],
@@ -253,7 +215,6 @@ describe('DiffsStoreActions', () => {
commit: () => {},
state: {
endpointBatch: `${endpointBatch}?view=${otherView}`,
- useSingleDiffStyle: true,
diffViewType: viewStyle,
},
})
@@ -283,7 +244,7 @@ describe('DiffsStoreActions', () => {
testAction(
fetchDiffFilesMeta,
{},
- { endpointMetadata },
+ { endpointMetadata, diffViewType: 'inline' },
[
{ type: types.SET_LOADING, payload: true },
{ type: types.SET_LOADING, payload: false },
@@ -299,146 +260,6 @@ describe('DiffsStoreActions', () => {
});
});
- describe('when the single diff view feature flag is off', () => {
- describe('fetchDiffFiles', () => {
- it('should fetch diff files', done => {
- const endpoint = '/fetch/diff/files?w=1';
- const mock = new MockAdapter(axios);
- const res = { diff_files: 1, merge_request_diffs: [] };
- mock.onGet(endpoint).reply(200, res);
-
- testAction(
- fetchDiffFiles,
- {},
- {
- endpoint,
- diffFiles: [],
- showWhitespace: false,
- diffViewType: 'inline',
- useSingleDiffStyle: false,
- currentDiffFileId: null,
- },
- [
- { type: types.SET_LOADING, payload: true },
- { type: types.SET_LOADING, payload: false },
- { type: types.SET_MERGE_REQUEST_DIFFS, payload: res.merge_request_diffs },
- { type: types.SET_DIFF_DATA, payload: res },
- ],
- [],
- () => {
- mock.restore();
- done();
- },
- );
-
- fetchDiffFiles({ state: { endpoint }, commit: () => null })
- .then(data => {
- expect(data).toEqual(res);
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('fetchDiffFilesBatch', () => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should fetch batch diff files', done => {
- const endpointBatch = '/fetch/diffs_batch';
- const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { next_page: 2 } };
- const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: {} };
- mock
- .onGet(mergeUrlParams({ per_page: DIFFS_PER_PAGE, w: '1', page: 1 }, endpointBatch))
- .reply(200, res1)
- .onGet(mergeUrlParams({ per_page: DIFFS_PER_PAGE, w: '1', page: 2 }, endpointBatch))
- .reply(200, res2);
-
- testAction(
- fetchDiffFilesBatch,
- {},
- { endpointBatch, useSingleDiffStyle: false, currentDiffFileId: null },
- [
- { type: types.SET_BATCH_LOADING, payload: true },
- { type: types.SET_RETRIEVING_BATCHES, payload: true },
- { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } },
- { type: types.SET_BATCH_LOADING, payload: false },
- { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test' },
- { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } },
- { type: types.SET_BATCH_LOADING, payload: false },
- { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test2' },
- { type: types.SET_RETRIEVING_BATCHES, payload: false },
- ],
- [],
- done,
- );
- });
-
- it.each`
- querystrings | requestUrl
- ${'?view=parallel'} | ${'/fetch/diffs_batch?view=parallel'}
- ${'?view=inline'} | ${'/fetch/diffs_batch?view=inline'}
- ${''} | ${'/fetch/diffs_batch'}
- `(
- 'should use the endpoint $requestUrl if the endpointBatch in state includes `$querystrings` as a querystring',
- ({ querystrings, requestUrl }) => {
- const endpointBatch = '/fetch/diffs_batch';
-
- fetchDiffFilesBatch({
- commit: () => {},
- state: {
- endpointBatch: `${endpointBatch}${querystrings}`,
- diffViewType: 'inline',
- },
- })
- .then(() => {
- expect(mock.history.get[0].url).toEqual(requestUrl);
- })
- .catch(() => {});
- },
- );
- });
-
- describe('fetchDiffFilesMeta', () => {
- const endpointMetadata = '/fetch/diffs_metadata.json';
- const noFilesData = { ...diffMetadata };
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- delete noFilesData.diff_files;
-
- mock.onGet(endpointMetadata).reply(200, diffMetadata);
- });
- it('should fetch diff meta information', done => {
- testAction(
- fetchDiffFilesMeta,
- {},
- { endpointMetadata, useSingleDiffStyle: false },
- [
- { type: types.SET_LOADING, payload: true },
- { type: types.SET_LOADING, payload: false },
- { type: types.SET_MERGE_REQUEST_DIFFS, payload: diffMetadata.merge_request_diffs },
- { type: types.SET_DIFF_DATA, payload: noFilesData },
- ],
- [],
- () => {
- mock.restore();
- done();
- },
- );
- });
- });
- });
-
describe('fetchCoverageFiles', () => {
let mock;
const endpointCoverage = '/fetch';
@@ -479,7 +300,7 @@ describe('DiffsStoreActions', () => {
it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => {
testAction(setHighlightedRow, 'ABC_123', {}, [
{ type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' },
- { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'ABC' },
+ { type: types.VIEW_DIFF_FILE, payload: 'ABC' },
]);
});
});
@@ -589,7 +410,7 @@ describe('DiffsStoreActions', () => {
testAction(
assignDiscussionsToDiff,
[],
- { diffFiles: [], useSingleDiffStyle: true },
+ { diffFiles: [] },
[],
[{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }],
done,
@@ -1083,7 +904,7 @@ describe('DiffsStoreActions', () => {
expect(document.location.hash).toBe('#test');
});
- it('commits UPDATE_CURRENT_DIFF_FILE_ID', () => {
+ it('commits VIEW_DIFF_FILE', () => {
const state = {
treeEntries: {
path: {
@@ -1094,7 +915,7 @@ describe('DiffsStoreActions', () => {
scrollToFile({ state, commit }, 'path');
- expect(commit).toHaveBeenCalledWith(types.UPDATE_CURRENT_DIFF_FILE_ID, 'test');
+ expect(commit).toHaveBeenCalledWith(types.VIEW_DIFF_FILE, 'test');
});
});
@@ -1592,7 +1413,7 @@ describe('DiffsStoreActions', () => {
});
describe('setCurrentDiffFileIdFromNote', () => {
- it('commits UPDATE_CURRENT_DIFF_FILE_ID', () => {
+ it('commits VIEW_DIFF_FILE', () => {
const commit = jest.fn();
const state = { diffFiles: [{ file_hash: '123' }] };
const rootGetters = {
@@ -1602,10 +1423,10 @@ describe('DiffsStoreActions', () => {
setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
- expect(commit).toHaveBeenCalledWith(types.UPDATE_CURRENT_DIFF_FILE_ID, '123');
+ expect(commit).toHaveBeenCalledWith(types.VIEW_DIFF_FILE, '123');
});
- it('does not commit UPDATE_CURRENT_DIFF_FILE_ID when discussion has no diff_file', () => {
+ it('does not commit VIEW_DIFF_FILE when discussion has no diff_file', () => {
const commit = jest.fn();
const state = { diffFiles: [{ file_hash: '123' }] };
const rootGetters = {
@@ -1618,7 +1439,7 @@ describe('DiffsStoreActions', () => {
expect(commit).not.toHaveBeenCalled();
});
- it('does not commit UPDATE_CURRENT_DIFF_FILE_ID when diff file does not exist', () => {
+ it('does not commit VIEW_DIFF_FILE when diff file does not exist', () => {
const commit = jest.fn();
const state = { diffFiles: [{ file_hash: '123' }] };
const rootGetters = {
@@ -1633,12 +1454,12 @@ describe('DiffsStoreActions', () => {
});
describe('navigateToDiffFileIndex', () => {
- it('commits UPDATE_CURRENT_DIFF_FILE_ID', done => {
+ it('commits VIEW_DIFF_FILE', done => {
testAction(
navigateToDiffFileIndex,
0,
{ diffFiles: [{ file_hash: '123' }] },
- [{ type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: '123' }],
+ [{ type: types.VIEW_DIFF_FILE, payload: '123' }],
[],
done,
);
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index 70047899612..e1d855ae0cf 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -11,13 +11,11 @@ describe('DiffsStoreMutations', () => {
const state = {};
const endpoint = '/diffs/endpoint';
const projectPath = '/root/project';
- const useSingleDiffStyle = false;
- mutations[types.SET_BASE_CONFIG](state, { endpoint, projectPath, useSingleDiffStyle });
+ mutations[types.SET_BASE_CONFIG](state, { endpoint, projectPath });
expect(state.endpoint).toEqual(endpoint);
expect(state.projectPath).toEqual(projectPath);
- expect(state.useSingleDiffStyle).toEqual(useSingleDiffStyle);
});
});
@@ -70,12 +68,13 @@ describe('DiffsStoreMutations', () => {
});
describe('SET_DIFF_DATA', () => {
- it('should set diff data type properly', () => {
+ it('should not modify the existing state', () => {
const state = {
diffFiles: [
{
- ...diffFileMockData,
- parallel_diff_lines: [],
+ content_sha: diffFileMockData.content_sha,
+ file_hash: diffFileMockData.file_hash,
+ highlighted_diff_lines: [],
},
],
};
@@ -85,43 +84,7 @@ describe('DiffsStoreMutations', () => {
mutations[types.SET_DIFF_DATA](state, diffMock);
- const firstLine = state.diffFiles[0].parallel_diff_lines[0];
-
- expect(firstLine.right.text).toBeUndefined();
- expect(state.diffFiles.length).toEqual(1);
- expect(state.diffFiles[0].renderIt).toEqual(true);
- expect(state.diffFiles[0].collapsed).toEqual(false);
- });
-
- describe('given diffsBatchLoad feature flag is enabled', () => {
- beforeEach(() => {
- gon.features = { diffsBatchLoad: true };
- });
-
- afterEach(() => {
- delete gon.features;
- });
-
- it('should not modify the existing state', () => {
- const state = {
- diffFiles: [
- {
- content_sha: diffFileMockData.content_sha,
- file_hash: diffFileMockData.file_hash,
- highlighted_diff_lines: [],
- },
- ],
- };
- const diffMock = {
- diff_files: [diffFileMockData],
- };
-
- mutations[types.SET_DIFF_DATA](state, diffMock);
-
- // If the batch load is enabled, there shouldn't be any processing
- // done on the existing state object, so we shouldn't have this.
- expect(state.diffFiles[0].parallel_diff_lines).toBeUndefined();
- });
+ expect(state.diffFiles[0].parallel_diff_lines).toBeUndefined();
});
});
@@ -682,6 +645,36 @@ describe('DiffsStoreMutations', () => {
expect(state.diffFiles[0].highlighted_diff_lines[0].discussions).toHaveLength(1);
expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toBe(1);
});
+
+ it('should add discussion to file', () => {
+ const state = {
+ latestDiff: true,
+ diffFiles: [
+ {
+ file_hash: 'ABC',
+ discussions: [],
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [],
+ },
+ ],
+ };
+ const discussion = {
+ id: 1,
+ line_code: 'ABC_1',
+ diff_discussion: true,
+ resolvable: true,
+ diff_file: {
+ file_hash: state.diffFiles[0].file_hash,
+ },
+ };
+
+ mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, {
+ discussion,
+ diffPositionByLineCode: null,
+ });
+
+ expect(state.diffFiles[0].discussions.length).toEqual(1);
+ });
});
describe('REMOVE_LINE_DISCUSSIONS', () => {
@@ -774,11 +767,11 @@ describe('DiffsStoreMutations', () => {
});
});
- describe('UPDATE_CURRENT_DIFF_FILE_ID', () => {
+ describe('VIEW_DIFF_FILE', () => {
it('updates currentDiffFileId', () => {
const state = createState();
- mutations[types.UPDATE_CURRENT_DIFF_FILE_ID](state, 'somefileid');
+ mutations[types.VIEW_DIFF_FILE](state, 'somefileid');
expect(state.currentDiffFileId).toBe('somefileid');
});
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 62c82468ea0..39a482c85ae 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -1167,4 +1167,59 @@ describe('DiffsStoreUtils', () => {
expect(utils.getDefaultWhitespace(undefined, '0')).toBe(true);
});
});
+
+ describe('isAdded', () => {
+ it.each`
+ type | expected
+ ${'new'} | ${true}
+ ${'new-nonewline'} | ${true}
+ ${'old'} | ${false}
+ `('returns $expected when type is $type', ({ type, expected }) => {
+ expect(utils.isAdded({ type })).toBe(expected);
+ });
+ });
+
+ describe('isRemoved', () => {
+ it.each`
+ type | expected
+ ${'old'} | ${true}
+ ${'old-nonewline'} | ${true}
+ ${'new'} | ${false}
+ `('returns $expected when type is $type', ({ type, expected }) => {
+ expect(utils.isRemoved({ type })).toBe(expected);
+ });
+ });
+
+ describe('isUnchanged', () => {
+ it.each`
+ type | expected
+ ${null} | ${true}
+ ${'new'} | ${false}
+ ${'old'} | ${false}
+ `('returns $expected when type is $type', ({ type, expected }) => {
+ expect(utils.isUnchanged({ type })).toBe(expected);
+ });
+ });
+
+ describe('isMeta', () => {
+ it.each`
+ type | expected
+ ${'match'} | ${true}
+ ${'new-nonewline'} | ${true}
+ ${'old-nonewline'} | ${true}
+ ${'new'} | ${false}
+ `('returns $expected when type is $type', ({ type, expected }) => {
+ expect(utils.isMeta({ type })).toBe(expected);
+ });
+ });
+
+ describe('parallelizeDiffLines', () => {
+ it('converts inline diff lines to parallel diff lines', () => {
+ const file = getDiffFileMock();
+
+ expect(utils.parallelizeDiffLines(file.highlighted_diff_lines)).toEqual(
+ file.parallel_diff_lines,
+ );
+ });
+ });
});
diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js
index e4edeab172b..e566d3a4b38 100644
--- a/spec/frontend/editor/editor_lite_spec.js
+++ b/spec/frontend/editor/editor_lite_spec.js
@@ -1,8 +1,7 @@
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
import Editor from '~/editor/editor_lite';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
-
-const URI_PREFIX = 'gitlab';
+import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from '~/editor/constants';
describe('Base editor', () => {
let editorEl;
@@ -27,9 +26,7 @@ describe('Base editor', () => {
it('initializes Editor with basic properties', () => {
expect(editor).toBeDefined();
- expect(editor.editorEl).toBe(null);
- expect(editor.blobContent).toEqual('');
- expect(editor.blobPath).toEqual('');
+ expect(editor.instances).toEqual([]);
});
it('removes `editor-loading` data attribute from the target DOM element', () => {
@@ -51,15 +48,14 @@ describe('Base editor', () => {
instanceSpy = jest.spyOn(monacoEditor, 'create').mockImplementation(() => ({
setModel,
dispose,
+ onDidDispose: jest.fn(),
}));
});
- it('does nothing if no dom element is supplied', () => {
- editor.createInstance();
-
- expect(editor.editorEl).toBe(null);
- expect(editor.blobContent).toEqual('');
- expect(editor.blobPath).toEqual('');
+ it('throws an error if no dom element is supplied', () => {
+ expect(() => {
+ editor.createInstance();
+ }).toThrow(EDITOR_LITE_INSTANCE_ERROR_NO_EL);
expect(modelSpy).not.toHaveBeenCalled();
expect(instanceSpy).not.toHaveBeenCalled();
@@ -89,15 +85,133 @@ describe('Base editor', () => {
createUri(blobGlobalId, blobPath),
);
});
+
+ it('initializes instance with passed properties', () => {
+ const instanceOptions = {
+ foo: 'bar',
+ };
+ editor.createInstance({
+ el: editorEl,
+ ...instanceOptions,
+ });
+ expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.objectContaining(instanceOptions));
+ });
+
+ it('disposes instance when the editor is disposed', () => {
+ editor.createInstance({ el: editorEl, blobPath, blobContent, blobGlobalId });
+
+ expect(dispose).not.toHaveBeenCalled();
+
+ editor.dispose();
+
+ expect(dispose).toHaveBeenCalled();
+ });
+ });
+
+ describe('multiple instances', () => {
+ let instanceSpy;
+ let inst1Args;
+ let inst2Args;
+ let editorEl1;
+ let editorEl2;
+ let inst1;
+ let inst2;
+ const readOnlyIndex = '68'; // readOnly option has the internal index of 68 in the editor's options
+
+ beforeEach(() => {
+ setFixtures('<div id="editor1"></div><div id="editor2"></div>');
+ editorEl1 = document.getElementById('editor1');
+ editorEl2 = document.getElementById('editor2');
+ inst1Args = {
+ el: editorEl1,
+ blobGlobalId,
+ };
+ inst2Args = {
+ el: editorEl2,
+ blobContent,
+ blobPath,
+ blobGlobalId,
+ };
+
+ editor = new Editor();
+ instanceSpy = jest.spyOn(monacoEditor, 'create');
+ });
+
+ afterEach(() => {
+ editor.dispose();
+ });
+
+ it('can initialize several instances of the same editor', () => {
+ editor.createInstance(inst1Args);
+ expect(editor.instances).toHaveLength(1);
+
+ editor.createInstance(inst2Args);
+
+ expect(instanceSpy).toHaveBeenCalledTimes(2);
+ expect(editor.instances).toHaveLength(2);
+ });
+
+ it('sets independent models on independent instances', () => {
+ inst1 = editor.createInstance(inst1Args);
+ inst2 = editor.createInstance(inst2Args);
+
+ const model1 = inst1.getModel();
+ const model2 = inst2.getModel();
+
+ expect(model1).toBeDefined();
+ expect(model2).toBeDefined();
+ expect(model1).not.toEqual(model2);
+ });
+
+ it('shares global editor options among all instances', () => {
+ editor = new Editor({
+ readOnly: true,
+ });
+
+ inst1 = editor.createInstance(inst1Args);
+ expect(inst1.getOption(readOnlyIndex)).toBe(true);
+
+ inst2 = editor.createInstance(inst2Args);
+ expect(inst2.getOption(readOnlyIndex)).toBe(true);
+ });
+
+ it('allows overriding editor options on the instance level', () => {
+ editor = new Editor({
+ readOnly: true,
+ });
+ inst1 = editor.createInstance({
+ ...inst1Args,
+ readOnly: false,
+ });
+
+ expect(inst1.getOption(readOnlyIndex)).toBe(false);
+ });
+
+ it('disposes instances and relevant models independently from each other', () => {
+ inst1 = editor.createInstance(inst1Args);
+ inst2 = editor.createInstance(inst2Args);
+
+ expect(inst1.getModel()).not.toBe(null);
+ expect(inst2.getModel()).not.toBe(null);
+ expect(editor.instances).toHaveLength(2);
+ expect(monacoEditor.getModels()).toHaveLength(2);
+
+ inst1.dispose();
+ expect(inst1.getModel()).toBe(null);
+ expect(inst2.getModel()).not.toBe(null);
+ expect(editor.instances).toHaveLength(1);
+ expect(monacoEditor.getModels()).toHaveLength(1);
+ });
});
describe('implementation', () => {
+ let instance;
beforeEach(() => {
- editor.createInstance({ el: editorEl, blobPath, blobContent });
+ instance = editor.createInstance({ el: editorEl, blobPath, blobContent });
});
it('correctly proxies value from the model', () => {
- expect(editor.getValue()).toEqual(blobContent);
+ expect(instance.getValue()).toBe(blobContent);
});
it('is capable of changing the language of the model', () => {
@@ -108,24 +222,25 @@ describe('Base editor', () => {
const blobRenamedPath = 'test.js';
- expect(editor.model.getLanguageIdentifier().language).toEqual('markdown');
- editor.updateModelLanguage(blobRenamedPath);
+ expect(instance.getModel().getLanguageIdentifier().language).toBe('markdown');
+ instance.updateModelLanguage(blobRenamedPath);
- expect(editor.model.getLanguageIdentifier().language).toEqual('javascript');
+ expect(instance.getModel().getLanguageIdentifier().language).toBe('javascript');
});
it('falls back to plaintext if there is no language associated with an extension', () => {
const blobRenamedPath = 'test.myext';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
- editor.updateModelLanguage(blobRenamedPath);
+ instance.updateModelLanguage(blobRenamedPath);
expect(spy).not.toHaveBeenCalled();
- expect(editor.model.getLanguageIdentifier().language).toEqual('plaintext');
+ expect(instance.getModel().getLanguageIdentifier().language).toBe('plaintext');
});
});
describe('extensions', () => {
+ let instance;
const foo1 = jest.fn();
const foo2 = jest.fn();
const bar = jest.fn();
@@ -139,14 +254,14 @@ describe('Base editor', () => {
foo: foo2,
};
beforeEach(() => {
- editor.createInstance({ el: editorEl, blobPath, blobContent });
+ instance = editor.createInstance({ el: editorEl, blobPath, blobContent });
});
it('is extensible with the extensions', () => {
- expect(editor.foo).toBeUndefined();
+ expect(instance.foo).toBeUndefined();
editor.use(MyExt1);
- expect(editor.foo).toEqual(foo1);
+ expect(instance.foo).toEqual(foo1);
});
it('does not fail if no extensions supplied', () => {
@@ -157,18 +272,18 @@ describe('Base editor', () => {
});
it('is extensible with multiple extensions', () => {
- expect(editor.foo).toBeUndefined();
- expect(editor.bar).toBeUndefined();
+ expect(instance.foo).toBeUndefined();
+ expect(instance.bar).toBeUndefined();
editor.use([MyExt1, MyExt2]);
- expect(editor.foo).toEqual(foo1);
- expect(editor.bar).toEqual(bar);
+ expect(instance.foo).toEqual(foo1);
+ expect(instance.bar).toEqual(bar);
});
it('uses the last definition of a method in case of an overlap', () => {
editor.use([MyExt1, MyExt2, MyExt3]);
- expect(editor).toEqual(
+ expect(instance).toEqual(
expect.objectContaining({
foo: foo2,
bar,
@@ -179,15 +294,47 @@ describe('Base editor', () => {
it('correctly resolves references withing extensions', () => {
const FunctionExt = {
inst() {
- return this.instance;
+ return this;
},
mod() {
- return this.model;
+ return this.getModel();
},
};
editor.use(FunctionExt);
- expect(editor.inst()).toEqual(editor.instance);
- expect(editor.mod()).toEqual(editor.model);
+ expect(instance.inst()).toEqual(editor.instances[0]);
+ });
+
+ describe('multiple instances', () => {
+ let inst1;
+ let inst2;
+ let editorEl1;
+ let editorEl2;
+
+ beforeEach(() => {
+ setFixtures('<div id="editor1"></div><div id="editor2"></div>');
+ editorEl1 = document.getElementById('editor1');
+ editorEl2 = document.getElementById('editor2');
+ inst1 = editor.createInstance({ el: editorEl1, blobPath: `foo-${blobPath}` });
+ inst2 = editor.createInstance({ el: editorEl2, blobPath: `bar-${blobPath}` });
+ });
+
+ afterEach(() => {
+ editor.dispose();
+ editorEl1.remove();
+ editorEl2.remove();
+ });
+
+ it('extends all instances if no specific instance is passed', () => {
+ editor.use(MyExt1);
+ expect(inst1.foo).toEqual(foo1);
+ expect(inst2.foo).toEqual(foo1);
+ });
+
+ it('extends specific instance if it has been passed', () => {
+ editor.use(MyExt1, inst2);
+ expect(inst1.foo).toBeUndefined();
+ expect(inst2.foo).toEqual(foo1);
+ });
});
});
diff --git a/spec/frontend/editor/editor_markdown_ext_spec.js b/spec/frontend/editor/editor_markdown_ext_spec.js
index b0fabad8542..30ab29aad35 100644
--- a/spec/frontend/editor/editor_markdown_ext_spec.js
+++ b/spec/frontend/editor/editor_markdown_ext_spec.js
@@ -4,6 +4,7 @@ import EditorMarkdownExtension from '~/editor/editor_markdown_ext';
describe('Markdown Extension for Editor Lite', () => {
let editor;
+ let instance;
let editorEl;
const firstLine = 'This is a';
const secondLine = 'multiline';
@@ -13,19 +14,19 @@ describe('Markdown Extension for Editor Lite', () => {
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn);
- editor.instance.setSelection(selection);
+ instance.setSelection(selection);
};
const selectSecondString = () => setSelection(2, 1, 2, secondLine.length + 1); // select the whole second line
const selectSecondAndThirdLines = () => setSelection(2, 1, 3, thirdLine.length + 1); // select second and third lines
- const selectionToString = () => editor.instance.getSelection().toString();
- const positionToString = () => editor.instance.getPosition().toString();
+ const selectionToString = () => instance.getSelection().toString();
+ const positionToString = () => instance.getPosition().toString();
beforeEach(() => {
setFixtures('<div id="editor" data-editor-loading></div>');
editorEl = document.getElementById('editor');
editor = new EditorLite();
- editor.createInstance({
+ instance = editor.createInstance({
el: editorEl,
blobPath: filePath,
blobContent: text,
@@ -34,17 +35,16 @@ describe('Markdown Extension for Editor Lite', () => {
});
afterEach(() => {
- editor.instance.dispose();
- editor.model.dispose();
+ instance.dispose();
editorEl.remove();
});
describe('getSelectedText', () => {
it('does not fail if there is no selection and returns the empty string', () => {
- jest.spyOn(editor.instance, 'getSelection');
- const resText = editor.getSelectedText();
+ jest.spyOn(instance, 'getSelection');
+ const resText = instance.getSelectedText();
- expect(editor.instance.getSelection).toHaveBeenCalled();
+ expect(instance.getSelection).toHaveBeenCalled();
expect(resText).toBe('');
});
@@ -56,7 +56,7 @@ describe('Markdown Extension for Editor Lite', () => {
`('correctly returns selected text for $description', ({ selection, expectedString }) => {
setSelection(...selection);
- const resText = editor.getSelectedText();
+ const resText = instance.getSelectedText();
expect(resText).toBe(expectedString);
});
@@ -65,7 +65,7 @@ describe('Markdown Extension for Editor Lite', () => {
selectSecondString();
const firstLineSelection = new Range(1, 1, 1, firstLine.length + 1);
- const resText = editor.getSelectedText(firstLineSelection);
+ const resText = instance.getSelectedText(firstLineSelection);
expect(resText).toBe(firstLine);
});
@@ -76,25 +76,25 @@ describe('Markdown Extension for Editor Lite', () => {
it('replaces selected text with the supplied one', () => {
selectSecondString();
- editor.replaceSelectedText(expectedStr);
+ instance.replaceSelectedText(expectedStr);
- expect(editor.getValue()).toBe(`${firstLine}\n${expectedStr}\n${thirdLine}`);
+ expect(instance.getValue()).toBe(`${firstLine}\n${expectedStr}\n${thirdLine}`);
});
it('prepends the supplied text if no text is selected', () => {
- editor.replaceSelectedText(expectedStr);
- expect(editor.getValue()).toBe(`${expectedStr}${firstLine}\n${secondLine}\n${thirdLine}`);
+ instance.replaceSelectedText(expectedStr);
+ expect(instance.getValue()).toBe(`${expectedStr}${firstLine}\n${secondLine}\n${thirdLine}`);
});
it('replaces selection with empty string if no text is supplied', () => {
selectSecondString();
- editor.replaceSelectedText();
- expect(editor.getValue()).toBe(`${firstLine}\n\n${thirdLine}`);
+ instance.replaceSelectedText();
+ expect(instance.getValue()).toBe(`${firstLine}\n\n${thirdLine}`);
});
it('puts cursor at the end of the new string and collapses selection by default', () => {
selectSecondString();
- editor.replaceSelectedText(expectedStr);
+ instance.replaceSelectedText(expectedStr);
expect(positionToString()).toBe(`(2,${expectedStr.length + 1})`);
expect(selectionToString()).toBe(
@@ -106,7 +106,7 @@ describe('Markdown Extension for Editor Lite', () => {
const select = 'url';
const complexReplacementString = `[${secondLine}](${select})`;
selectSecondString();
- editor.replaceSelectedText(complexReplacementString, select);
+ instance.replaceSelectedText(complexReplacementString, select);
expect(positionToString()).toBe(`(2,${complexReplacementString.length + 1})`);
expect(selectionToString()).toBe(`[2,1 -> 2,${complexReplacementString.length + 1}]`);
@@ -116,7 +116,7 @@ describe('Markdown Extension for Editor Lite', () => {
describe('moveCursor', () => {
const setPosition = endCol => {
const currentPos = new Position(2, endCol);
- editor.instance.setPosition(currentPos);
+ instance.setPosition(currentPos);
};
it.each`
@@ -136,9 +136,9 @@ describe('Markdown Extension for Editor Lite', () => {
({ startColumn, shift, endPosition }) => {
setPosition(startColumn);
if (Array.isArray(shift)) {
- editor.moveCursor(...shift);
+ instance.moveCursor(...shift);
} else {
- editor.moveCursor(shift);
+ instance.moveCursor(shift);
}
expect(positionToString()).toBe(endPosition);
},
@@ -153,18 +153,18 @@ describe('Markdown Extension for Editor Lite', () => {
expect(selectionToString()).toBe(`[2,1 -> 3,${thirdLine.length + 1}]`);
- editor.selectWithinSelection(toSelect, selectedText);
+ instance.selectWithinSelection(toSelect, selectedText);
expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`);
});
it('does not fail when only `toSelect` is supplied and fetches the text from selection', () => {
- jest.spyOn(editor, 'getSelectedText');
+ jest.spyOn(instance, 'getSelectedText');
const toSelect = 'string';
selectSecondAndThirdLines();
- editor.selectWithinSelection(toSelect);
+ instance.selectWithinSelection(toSelect);
- expect(editor.getSelectedText).toHaveBeenCalled();
+ expect(instance.getSelectedText).toHaveBeenCalled();
expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`);
});
@@ -176,7 +176,7 @@ describe('Markdown Extension for Editor Lite', () => {
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
- editor.selectWithinSelection();
+ instance.selectWithinSelection();
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
@@ -190,12 +190,12 @@ describe('Markdown Extension for Editor Lite', () => {
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
- editor.selectWithinSelection(toSelect);
+ instance.selectWithinSelection(toSelect);
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
- editor.selectWithinSelection();
+ instance.selectWithinSelection();
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
diff --git a/spec/frontend/emoji/emoji_spec.js b/spec/frontend/emoji/emoji_spec.js
index 9b49c8b8ab5..53c6d0835bc 100644
--- a/spec/frontend/emoji/emoji_spec.js
+++ b/spec/frontend/emoji/emoji_spec.js
@@ -55,11 +55,10 @@ const emojiFixtureMap = {
describe('gl_emoji', () => {
let mock;
- const emojiData = getJSONFixture('emojis/emojis.json');
beforeEach(() => {
mock = new MockAdapter(axios);
- mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData);
+ mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200);
return initEmojiMap().catch(() => {});
});
diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js
index e7f5ee4bc4d..ebdc4923045 100644
--- a/spec/frontend/environments/environment_actions_spec.js
+++ b/spec/frontend/environments/environment_actions_spec.js
@@ -1,9 +1,8 @@
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import eventHub from '~/environments/event_hub';
import EnvironmentActions from '~/environments/components/environment_actions.vue';
-import Icon from '~/vue_shared/components/icon.vue';
describe('EnvironmentActions Component', () => {
let vm;
@@ -17,7 +16,7 @@ describe('EnvironmentActions Component', () => {
});
it('should render a dropdown button with 2 icons', () => {
- expect(vm.find('.dropdown-new').findAll(Icon).length).toBe(2);
+ expect(vm.find('.dropdown-new').findAll(GlIcon).length).toBe(2);
});
it('should render a dropdown button with aria-label description', () => {
@@ -60,11 +59,7 @@ describe('EnvironmentActions Component', () => {
});
it("should render a disabled action when it's not playable", () => {
- expect(vm.find('.dropdown-menu li:last-child button').attributes('disabled')).toEqual(
- 'disabled',
- );
-
- expect(vm.find('.dropdown-menu li:last-child button').classes('disabled')).toBe(true);
+ expect(vm.find('.dropdown-menu li:last-child gl-button-stub').props('disabled')).toBe(true);
});
});
@@ -82,7 +77,7 @@ describe('EnvironmentActions Component', () => {
scheduledAt: '2018-10-05T08:23:00Z',
};
const findDropdownItem = action => {
- const buttons = vm.findAll('.dropdown-menu li button');
+ const buttons = vm.findAll('.dropdown-menu li gl-button-stub');
return buttons.filter(button => button.text().startsWith(action.name)).at(0);
};
@@ -96,7 +91,7 @@ describe('EnvironmentActions Component', () => {
eventHub.$on('postAction', emitSpy);
jest.spyOn(window, 'confirm').mockImplementation(() => true);
- findDropdownItem(scheduledJobAction).trigger('click');
+ findDropdownItem(scheduledJobAction).vm.$emit('click');
expect(window.confirm).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath });
@@ -107,7 +102,7 @@ describe('EnvironmentActions Component', () => {
eventHub.$on('postAction', emitSpy);
jest.spyOn(window, 'confirm').mockImplementation(() => false);
- findDropdownItem(scheduledJobAction).trigger('click');
+ findDropdownItem(scheduledJobAction).vm.$emit('click');
expect(window.confirm).toHaveBeenCalled();
expect(emitSpy).not.toHaveBeenCalled();
diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js
index 5d374a162ab..1b429783821 100644
--- a/spec/frontend/environments/environment_item_spec.js
+++ b/spec/frontend/environments/environment_item_spec.js
@@ -3,7 +3,7 @@ import { format } from 'timeago.js';
import EnvironmentItem from '~/environments/components/environment_item.vue';
import PinComponent from '~/environments/components/environment_pin.vue';
import DeleteComponent from '~/environments/components/environment_delete.vue';
-
+import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
import { environment, folder, tableData } from './mock_data';
describe('Environment item', () => {
@@ -135,7 +135,7 @@ describe('Environment item', () => {
});
describe('in the past', () => {
- const pastDate = new Date(Date.now() - 100000);
+ const pastDate = new Date(differenceInMilliseconds(100000));
beforeEach(() => {
factory({
propsData: {
diff --git a/spec/frontend/environments/environment_monitoring_spec.js b/spec/frontend/environments/environment_monitoring_spec.js
index d2129bd7b30..a73f49f1047 100644
--- a/spec/frontend/environments/environment_monitoring_spec.js
+++ b/spec/frontend/environments/environment_monitoring_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
import MonitoringComponent from '~/environments/components/environment_monitoring.vue';
-import Icon from '~/vue_shared/components/icon.vue';
describe('Monitoring Component', () => {
let wrapper;
@@ -15,8 +15,8 @@ describe('Monitoring Component', () => {
});
};
- const findIcons = () => wrapper.findAll(Icon);
- const findIconsByName = name => findIcons().filter(icon => icon.props('name') === name);
+ const findButtons = () => wrapper.findAll(GlButton);
+ const findButtonsByIcon = icon => findButtons().filter(button => button.props('icon') === icon);
beforeEach(() => {
createWrapper();
@@ -30,7 +30,7 @@ describe('Monitoring Component', () => {
it('should render a link to environment monitoring page', () => {
expect(wrapper.attributes('href')).toEqual(monitoringUrl);
- expect(findIconsByName('chart').length).toBe(1);
+ expect(findButtonsByIcon('chart').length).toBe(1);
expect(wrapper.attributes('title')).toBe('Monitoring');
expect(wrapper.attributes('aria-label')).toBe('Monitoring');
});
diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js
index 486f6db7366..f48091adb44 100644
--- a/spec/frontend/environments/environment_pin_spec.js
+++ b/spec/frontend/environments/environment_pin_spec.js
@@ -1,6 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedButton } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
+import { GlButton, GlIcon } from '@gitlab/ui';
import eventHub from '~/environments/event_hub';
import PinComponent from '~/environments/components/environment_pin.vue';
@@ -32,12 +31,12 @@ describe('Pin Component', () => {
});
it('should render the component with thumbtack icon', () => {
- expect(wrapper.find(Icon).props('name')).toBe('thumbtack');
+ expect(wrapper.find(GlIcon).props('name')).toBe('thumbtack');
});
it('should emit onPinClick when clicked', () => {
const eventHubSpy = jest.spyOn(eventHub, '$emit');
- const button = wrapper.find(GlDeprecatedButton);
+ const button = wrapper.find(GlButton);
button.vm.$emit('click');
diff --git a/spec/frontend/environments/environment_rollback_spec.js b/spec/frontend/environments/environment_rollback_spec.js
index f25e05f9cd8..fb62a096c3d 100644
--- a/spec/frontend/environments/environment_rollback_spec.js
+++ b/spec/frontend/environments/environment_rollback_spec.js
@@ -1,5 +1,5 @@
import { shallowMount, mount } from '@vue/test-utils';
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import eventHub from '~/environments/event_hub';
import RollbackComponent from '~/environments/components/environment_rollback.vue';
@@ -40,7 +40,7 @@ describe('Rollback Component', () => {
},
},
});
- const button = wrapper.find(GlDeprecatedButton);
+ const button = wrapper.find(GlButton);
button.vm.$emit('click');
diff --git a/spec/frontend/environments/environment_terminal_button_spec.js b/spec/frontend/environments/environment_terminal_button_spec.js
index 007fda2f2cc..274186fbbd6 100644
--- a/spec/frontend/environments/environment_terminal_button_spec.js
+++ b/spec/frontend/environments/environment_terminal_button_spec.js
@@ -22,7 +22,7 @@ describe('Stop Component', () => {
});
it('should render a link to open a web terminal with the provided path', () => {
- expect(wrapper.is('a')).toBe(true);
+ expect(wrapper.element.tagName).toBe('A');
expect(wrapper.attributes('title')).toBe('Terminal');
expect(wrapper.attributes('aria-label')).toBe('Terminal');
expect(wrapper.attributes('href')).toBe(terminalPath);
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index d440bf73e15..fe32bf918dd 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -144,16 +144,16 @@ describe('Environment', () => {
});
it('should open a closed folder', () => {
- expect(wrapper.find('.folder-icon.ic-chevron-right').exists()).toBe(false);
+ expect(wrapper.find('.folder-icon[data-testid="chevron-right-icon"]').exists()).toBe(false);
});
it('should close an opened folder', () => {
- expect(wrapper.find('.folder-icon.ic-chevron-down').exists()).toBe(true);
+ expect(wrapper.find('.folder-icon[data-testid="chevron-down-icon"]').exists()).toBe(true);
// close folder
wrapper.find('.folder-name').trigger('click');
wrapper.vm.$nextTick(() => {
- expect(wrapper.find('.folder-icon.ic-chevron-down').exists()).toBe(false);
+ expect(wrapper.find('.folder-icon[data-testid="chevron-down-icon"]').exists()).toBe(false);
});
});
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 bad70a31599..31f355ce6f1 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -163,7 +163,7 @@ describe('ErrorTrackingList', () => {
it('each error in the list should have an action button set', () => {
findErrorListRows().wrappers.forEach(row => {
- expect(row.contains(ErrorTrackingActions)).toBe(true);
+ expect(row.find(ErrorTrackingActions).exists()).toBe(true);
});
});
@@ -259,23 +259,15 @@ describe('ErrorTrackingList', () => {
errorId: errorsList[0].id,
status: 'ignored',
});
- expect(actions.updateStatus).toHaveBeenCalledWith(
- expect.anything(),
- {
- endpoint: `/project/test/-/error_tracking/${errorsList[0].id}.json`,
- status: 'ignored',
- },
- undefined,
- );
+ expect(actions.updateStatus).toHaveBeenCalledWith(expect.anything(), {
+ endpoint: `/project/test/-/error_tracking/${errorsList[0].id}.json`,
+ status: 'ignored',
+ });
});
it('calls an action to remove the item from the list', () => {
findErrorActions().vm.$emit('update-issue-status', { errorId: '1', status: undefined });
- expect(actions.removeIgnoredResolvedErrors).toHaveBeenCalledWith(
- expect.anything(),
- '1',
- undefined,
- );
+ expect(actions.removeIgnoredResolvedErrors).toHaveBeenCalledWith(expect.anything(), '1');
});
});
@@ -298,23 +290,15 @@ describe('ErrorTrackingList', () => {
errorId: errorsList[0].id,
status: 'resolved',
});
- expect(actions.updateStatus).toHaveBeenCalledWith(
- expect.anything(),
- {
- endpoint: `/project/test/-/error_tracking/${errorsList[0].id}.json`,
- status: 'resolved',
- },
- undefined,
- );
+ expect(actions.updateStatus).toHaveBeenCalledWith(expect.anything(), {
+ endpoint: `/project/test/-/error_tracking/${errorsList[0].id}.json`,
+ status: 'resolved',
+ });
});
it('calls an action to remove the item from the list', () => {
findErrorActions().vm.$emit('update-issue-status', { errorId: '1', status: undefined });
- expect(actions.removeIgnoredResolvedErrors).toHaveBeenCalledWith(
- expect.anything(),
- '1',
- undefined,
- );
+ expect(actions.removeIgnoredResolvedErrors).toHaveBeenCalledWith(expect.anything(), '1');
});
});
@@ -443,7 +427,6 @@ describe('ErrorTrackingList', () => {
expect(actions.fetchPaginatedResults).toHaveBeenLastCalledWith(
expect.anything(),
'previousCursor',
- undefined,
);
});
});
@@ -462,7 +445,6 @@ describe('ErrorTrackingList', () => {
expect(actions.fetchPaginatedResults).toHaveBeenLastCalledWith(
expect.anything(),
'nextCursor',
- undefined,
);
});
});
diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
index df7bff201f1..6df25ad6819 100644
--- a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
+++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
@@ -1,10 +1,9 @@
import { shallowMount } from '@vue/test-utils';
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlIcon } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
-import Icon from '~/vue_shared/components/icon.vue';
describe('Stacktrace Entry', () => {
let wrapper;
@@ -39,7 +38,7 @@ describe('Stacktrace Entry', () => {
mountComponent({ lines });
expect(wrapper.find(StackTraceEntry).exists()).toBe(true);
expect(wrapper.find(ClipboardButton).exists()).toBe(true);
- expect(wrapper.find(Icon).exists()).toBe(true);
+ expect(wrapper.find(GlIcon).exists()).toBe(true);
expect(wrapper.find(FileIcon).exists()).toBe(true);
expect(wrapper.find('table').exists()).toBe(false);
});
@@ -57,7 +56,7 @@ describe('Stacktrace Entry', () => {
it('should hide collapse icon and render error fn name and error line when there is no code block', () => {
const extraInfo = { errorLine: 34, errorFn: 'errorFn', errorColumn: 77 };
mountComponent({ expanded: false, lines: [], ...extraInfo });
- expect(wrapper.find(Icon).exists()).toBe(false);
+ expect(wrapper.find(GlIcon).exists()).toBe(false);
expect(trimText(findFileHeaderContent())).toContain(
`in ${extraInfo.errorFn} at line ${extraInfo.errorLine}:${extraInfo.errorColumn}`,
);
diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb
index fcd68662acc..40f613a9422 100644
--- a/spec/frontend/fixtures/search.rb
+++ b/spec/frontend/fixtures/search.rb
@@ -7,10 +7,16 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do
render_views
+ let_it_be(:user) { create(:admin) }
+
before(:all) do
clean_frontend_fixtures('search/')
end
+ before do
+ sign_in(user)
+ end
+
it 'search/show.html' do
get :show
diff --git a/spec/frontend/fixtures/static/ajax_loading_spinner.html b/spec/frontend/fixtures/static/ajax_loading_spinner.html
deleted file mode 100644
index 0e1ebb32b1c..00000000000
--- a/spec/frontend/fixtures/static/ajax_loading_spinner.html
+++ /dev/null
@@ -1,3 +0,0 @@
-<a class="js-ajax-loading-spinner" data-remote href="http://goesnowhere.nothing/whereami">
-<i class="fa fa-trash-o"></i>
-</a>
diff --git a/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html b/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html
new file mode 100644
index 00000000000..3db9bafcb9f
--- /dev/null
+++ b/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html
@@ -0,0 +1,39 @@
+<div>
+ <div class="dropdown inline">
+ <button
+ class="dropdown-menu-toggle"
+ data-toggle="dropdown"
+ id="js-project-dropdown"
+ type="button"
+ >
+ <div class="dropdown-toggle-text">
+ Projects
+ </div>
+ <i class="fa fa-chevron-down dropdown-toggle-caret js-projects-dropdown-toggle"></i>
+ </button>
+ <div class="dropdown-menu dropdown-select dropdown-menu-selectable">
+ <div class="dropdown-title gl-display-flex gl-align-items-center">
+ <span class="gl-ml-auto">Go to project</span>
+ <button
+ aria-label="Close"
+ type="button"
+ class="btn dropdown-title-button dropdown-menu-close gl-ml-auto btn-default btn-sm gl-button btn-default-tertiary btn-icon"
+ >
+ <svg data-testid="close-icon" class="gl-icon s16 dropdown-menu-close-icon">
+ <use
+ href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#close"
+ ></use>
+ </svg>
+ </button>
+ </div>
+ <div class="dropdown-input">
+ <input class="dropdown-input-field" placeholder="Filter results" type="search" />
+ <i class="fa fa-search dropdown-input-search"></i>
+ </div>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-loading">
+ <i class="fa fa-spinner fa-spin"></i>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/spec/frontend/fixtures/static/gl_dropdown.html b/spec/frontend/fixtures/static/gl_dropdown.html
deleted file mode 100644
index 08f6738414e..00000000000
--- a/spec/frontend/fixtures/static/gl_dropdown.html
+++ /dev/null
@@ -1,26 +0,0 @@
-<div>
-<div class="dropdown inline">
-<button class="dropdown-menu-toggle" data-toggle="dropdown" id="js-project-dropdown" type="button">
-<div class="dropdown-toggle-text">
-Projects
-</div>
-<i class="fa fa-chevron-down dropdown-toggle-caret js-projects-dropdown-toggle"></i>
-</button>
-<div class="dropdown-menu dropdown-select dropdown-menu-selectable">
-<div class="dropdown-title">
-<span>Go to project</span>
-<button aria="{:label=&gt;&quot;Close&quot;}" class="dropdown-title-button dropdown-menu-close">
-<i class="fa fa-times dropdown-menu-close-icon"></i>
-</button>
-</div>
-<div class="dropdown-input">
-<input class="dropdown-input-field" placeholder="Filter results" type="search">
-<i class="fa fa-search dropdown-input-search"></i>
-</div>
-<div class="dropdown-content"></div>
-<div class="dropdown-loading">
-<i class="fa fa-spinner fa-spin"></i>
-</div>
-</div>
-</div>
-</div>
diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb
index be3874d7c42..a6a8ba7318b 100644
--- a/spec/frontend/fixtures/u2f.rb
+++ b/spec/frontend/fixtures/u2f.rb
@@ -11,6 +11,10 @@ RSpec.context 'U2F' do
clean_frontend_fixtures('u2f/')
end
+ before do
+ stub_feature_flags(webauthn: false)
+ end
+
describe SessionsController, '(JavaScript fixtures)', type: :controller do
include DeviseHelpers
diff --git a/spec/frontend/fixtures/webauthn.rb b/spec/frontend/fixtures/webauthn.rb
new file mode 100644
index 00000000000..b195fee76f0
--- /dev/null
+++ b/spec/frontend/fixtures/webauthn.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.context 'WebAuthn' do
+ include JavaScriptFixturesHelpers
+
+ let(:user) { create(:user, :two_factor_via_webauthn, otp_secret: 'otpsecret:coolkids') }
+
+ before(:all) do
+ clean_frontend_fixtures('webauthn/')
+ end
+
+ describe SessionsController, '(JavaScript fixtures)', type: :controller do
+ include DeviseHelpers
+
+ render_views
+
+ before do
+ set_devise_mapping(context: @request)
+ end
+
+ it 'webauthn/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 'webauthn/register.html' do
+ get :show
+
+ expect(response).to be_successful
+ end
+ end
+end
diff --git a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js
index 204bbfb9c2f..c5155315bb9 100644
--- a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js
+++ b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js
@@ -69,8 +69,8 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('template', () => {
it('should render component element', () => {
expect(wrapper.classes()).toContain('search-input-container');
- expect(wrapper.contains('input.form-control')).toBe(true);
- expect(wrapper.contains('.search-icon')).toBe(true);
+ expect(wrapper.find('input.form-control').exists()).toBe(true);
+ expect(wrapper.find('.search-icon').exists()).toBe(true);
expect(wrapper.find('input.form-control').attributes('placeholder')).toBe(
'Search your projects',
);
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 869347128e5..6c40b1ba3a7 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -1,11 +1,9 @@
/* eslint no-param-reassign: "off" */
import $ from 'jquery';
+import '~/lib/utils/jquery_at_who';
import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete';
-import 'jquery.caret';
-import '@gitlab/at.js';
-
import { TEST_HOST } from 'helpers/test_constants';
import { getJSONFixture } from 'helpers/fixtures';
@@ -121,7 +119,7 @@ describe('GfmAutoComplete', () => {
const defaultMatcher = (context, flag, subtext) =>
gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext);
- const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$'];
+ const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$', '+'];
const otherFlags = ['/', ':'];
const flags = flagsUseDefaultMatcher.concat(otherFlags);
@@ -155,7 +153,6 @@ describe('GfmAutoComplete', () => {
'я',
'.',
"'",
- '+',
'-',
'_',
];
diff --git a/spec/frontend/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js
index 809cc5c88e2..644b687aa19 100644
--- a/spec/frontend/gpg_badges_spec.js
+++ b/spec/frontend/gpg_badges_spec.js
@@ -68,7 +68,7 @@ describe('GpgBadges', () => {
GpgBadges.fetch()
.then(() => {
expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null);
- const spinners = document.querySelectorAll('.js-loading-gpg-badge i.fa.fa-spinner.fa-spin');
+ const spinners = document.querySelectorAll('.js-loading-gpg-badge span.gl-spinner');
expect(spinners.length).toBe(1);
done();
diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
index 0e16b726c4b..0befe1aa192 100644
--- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
+++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
@@ -83,7 +83,7 @@ exports[`grafana integration component default state to match the default snapsh
More information
- <icon-stub
+ <gl-icon-stub
class="vertical-align-middle"
name="external-link"
size="16"
diff --git a/spec/frontend/groups/components/invite_members_banner_spec.js b/spec/frontend/groups/components/invite_members_banner_spec.js
new file mode 100644
index 00000000000..95003b211fd
--- /dev/null
+++ b/spec/frontend/groups/components/invite_members_banner_spec.js
@@ -0,0 +1,144 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlBanner } from '@gitlab/ui';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { setCookie, parseBoolean } from '~/lib/utils/common_utils';
+import InviteMembersBanner from '~/groups/components/invite_members_banner.vue';
+
+jest.mock('~/lib/utils/common_utils');
+
+const isDismissedKey = 'invite_99_1';
+const title = 'Collaborate with your team';
+const body =
+ "We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge";
+const svgPath = '/illustrations/background';
+const inviteMembersPath = 'groups/members';
+const buttonText = 'Invite your colleagues';
+const trackLabel = 'invite_members_banner';
+
+const createComponent = (stubs = {}) => {
+ return shallowMount(InviteMembersBanner, {
+ provide: {
+ svgPath,
+ inviteMembersPath,
+ isDismissedKey,
+ trackLabel,
+ },
+ stubs,
+ });
+};
+
+describe('InviteMembersBanner', () => {
+ let wrapper;
+ let trackingSpy;
+
+ beforeEach(() => {
+ document.body.dataset.page = 'any:page';
+ trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ unmockTracking();
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ GlBanner });
+ });
+
+ const trackCategory = undefined;
+ const displayEvent = 'invite_members_banner_displayed';
+ const buttonClickEvent = 'invite_members_banner_button_clicked';
+ const dismissEvent = 'invite_members_banner_dismissed';
+
+ it('sends the displayEvent when the banner is displayed', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(trackCategory, displayEvent, {
+ label: trackLabel,
+ });
+ });
+
+ it('sets the button attributes for the buttonClickEvent', () => {
+ const button = wrapper.find(`[href='${wrapper.vm.inviteMembersPath}']`);
+
+ expect(button.attributes()).toMatchObject({
+ 'data-track-event': buttonClickEvent,
+ 'data-track-label': trackLabel,
+ });
+ });
+
+ it('sends the dismissEvent when the banner is dismissed', () => {
+ wrapper.find(GlBanner).vm.$emit('close');
+
+ expect(trackingSpy).toHaveBeenCalledWith(trackCategory, dismissEvent, {
+ label: trackLabel,
+ });
+ });
+ });
+
+ describe('rendering', () => {
+ const findBanner = () => {
+ return wrapper.find(GlBanner);
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('uses the svgPath for the banner svgpath', () => {
+ expect(findBanner().attributes('svgpath')).toBe(svgPath);
+ });
+
+ it('uses the title from options for title', () => {
+ expect(findBanner().attributes('title')).toBe(title);
+ });
+
+ it('includes the body text from options', () => {
+ expect(findBanner().html()).toContain(body);
+ });
+
+ it('uses the button_text text from options for buttontext', () => {
+ expect(findBanner().attributes('buttontext')).toBe(buttonText);
+ });
+
+ it('uses the href from inviteMembersPath for buttonlink', () => {
+ expect(findBanner().attributes('buttonlink')).toBe(inviteMembersPath);
+ });
+ });
+
+ describe('dismissing', () => {
+ const findButton = () => {
+ return wrapper.find('button');
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent({ GlBanner });
+
+ findButton().trigger('click');
+ });
+
+ it('sets iDismissed to true', () => {
+ expect(wrapper.vm.isDismissed).toBe(true);
+ });
+
+ it('sets the cookie with the isDismissedKey', () => {
+ expect(setCookie).toHaveBeenCalledWith(isDismissedKey, true);
+ });
+ });
+
+ describe('when a dismiss cookie exists', () => {
+ beforeEach(() => {
+ parseBoolean.mockReturnValue(true);
+
+ wrapper = createComponent({ GlBanner });
+ });
+
+ it('sets isDismissed to true', () => {
+ expect(wrapper.vm.isDismissed).toBe(true);
+ });
+
+ it('does not render the banner', () => {
+ expect(wrapper.contains(GlBanner)).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js
index c0dc1a816e6..f5df8c180d5 100644
--- a/spec/frontend/groups/components/item_actions_spec.js
+++ b/spec/frontend/groups/components/item_actions_spec.js
@@ -57,8 +57,8 @@ describe('ItemActionsComponent', () => {
expect(editBtn.getAttribute('href')).toBe(group.editPath);
expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
expect(editBtn.dataset.originalTitle).toBe('Edit group');
- expect(editBtn.querySelectorAll('svg use').length).not.toBe(0);
- expect(editBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#settings');
+ expect(editBtn.querySelectorAll('svg').length).not.toBe(0);
+ expect(editBtn.querySelector('svg').getAttribute('data-testid')).toBe('settings-icon');
newVm.$destroy();
});
@@ -75,8 +75,8 @@ describe('ItemActionsComponent', () => {
expect(leaveBtn.getAttribute('href')).toBe(group.leavePath);
expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group');
expect(leaveBtn.dataset.originalTitle).toBe('Leave this group');
- expect(leaveBtn.querySelectorAll('svg use').length).not.toBe(0);
- expect(leaveBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#leave');
+ expect(leaveBtn.querySelectorAll('svg').length).not.toBe(0);
+ expect(leaveBtn.querySelector('svg').getAttribute('data-testid')).toBe('leave-icon');
newVm.$destroy();
});
diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js
index bfe27be9b51..4ff7482414c 100644
--- a/spec/frontend/groups/components/item_caret_spec.js
+++ b/spec/frontend/groups/components/item_caret_spec.js
@@ -27,12 +27,12 @@ describe('ItemCaretComponent', () => {
it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
vm = createComponent(true);
- expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down');
+ expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-down-icon');
});
it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
vm = createComponent();
- expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right');
+ expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-right-icon');
});
});
});
diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js
index da6f145fa19..11246390444 100644
--- a/spec/frontend/groups/components/item_stats_value_spec.js
+++ b/spec/frontend/groups/components/item_stats_value_spec.js
@@ -72,7 +72,7 @@ describe('ItemStatsValueComponent', () => {
});
it('renders element icon correctly', () => {
- expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder');
+ expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-icon');
});
it('renders value count correctly', () => {
diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js
index 251b5b5ff4c..477c413ddcd 100644
--- a/spec/frontend/groups/components/item_type_icon_spec.js
+++ b/spec/frontend/groups/components/item_type_icon_spec.js
@@ -27,12 +27,12 @@ describe('ItemTypeIconComponent', () => {
vm = createComponent(ITEM_TYPE.GROUP, true);
- expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open');
+ expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-open-icon');
vm.$destroy();
vm = createComponent(ITEM_TYPE.GROUP);
- expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder');
+ expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-o-icon');
vm.$destroy();
});
@@ -41,12 +41,12 @@ describe('ItemTypeIconComponent', () => {
vm = createComponent(ITEM_TYPE.PROJECT);
- expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark');
+ expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('bookmark-icon');
vm.$destroy();
vm = createComponent(ITEM_TYPE.GROUP);
- expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark');
+ expect(vm.$el.querySelector('svg').getAttribute('data-testid')).not.toBe('bookmark-icon');
vm.$destroy();
});
});
diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js
new file mode 100644
index 00000000000..70fce0d60fb
--- /dev/null
+++ b/spec/frontend/groups/members/index_spec.js
@@ -0,0 +1,66 @@
+import { createWrapper } from '@vue/test-utils';
+import initGroupMembersApp from '~/groups/members';
+import GroupMembersApp from '~/groups/members/components/app.vue';
+import { membersJsonString, membersParsed } from './mock_data';
+
+describe('initGroupMembersApp', () => {
+ let el;
+ let vm;
+ let wrapper;
+
+ const setup = () => {
+ vm = initGroupMembersApp(el);
+ wrapper = createWrapper(vm);
+ };
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ el.setAttribute('data-members', membersJsonString);
+ el.setAttribute('data-group-id', '234');
+
+ window.gon = { current_user_id: 123 };
+
+ document.body.appendChild(el);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ el = null;
+
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders `GroupMembersApp`', () => {
+ setup();
+
+ expect(wrapper.find(GroupMembersApp).exists()).toBe(true);
+ });
+
+ it('sets `currentUserId` in Vuex store', () => {
+ setup();
+
+ expect(vm.$store.state.currentUserId).toBe(123);
+ });
+
+ describe('when `gon.current_user_id` is not set (user is not logged in)', () => {
+ it('sets `currentUserId` as `null` in Vuex store', () => {
+ window.gon = {};
+ setup();
+
+ expect(vm.$store.state.currentUserId).toBeNull();
+ });
+ });
+
+ it('parses and sets `data-group-id` as `sourceId` in Vuex store', () => {
+ setup();
+
+ expect(vm.$store.state.sourceId).toBe(234);
+ });
+
+ it('parses and sets `members` in Vuex store', () => {
+ setup();
+
+ expect(vm.$store.state.members).toEqual(membersParsed);
+ });
+});
diff --git a/spec/frontend/groups/members/mock_data.js b/spec/frontend/groups/members/mock_data.js
new file mode 100644
index 00000000000..b84c9c6d446
--- /dev/null
+++ b/spec/frontend/groups/members/mock_data.js
@@ -0,0 +1,33 @@
+export const membersJsonString =
+ '[{"requested_at":null,"can_update":true,"can_remove":true,"can_override":false,"access_level":{"integer_value":50,"string_value":"Owner"},"source":{"id":323,"name":"My group / my subgroup","web_url":"http://127.0.0.1:3000/groups/my-group/my-subgroup"},"user":{"id":1,"name":"Administrator","username":"root","web_url":"http://127.0.0.1:3000/root","avatar_url":"https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80\u0026d=identicon","blocked":false,"two_factor_enabled":false},"id":524,"created_at":"2020-08-21T21:33:27.631Z","expires_at":null,"using_license":false,"group_sso":false,"group_managed_account":false}]';
+
+export const membersParsed = [
+ {
+ requestedAt: null,
+ canUpdate: true,
+ canRemove: true,
+ canOverride: false,
+ accessLevel: { integerValue: 50, stringValue: 'Owner' },
+ source: {
+ id: 323,
+ name: 'My group / my subgroup',
+ webUrl: 'http://127.0.0.1:3000/groups/my-group/my-subgroup',
+ },
+ user: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon',
+ blocked: false,
+ twoFactorEnabled: false,
+ },
+ id: 524,
+ createdAt: '2020-08-21T21:33:27.631Z',
+ expiresAt: null,
+ usingLicense: false,
+ groupSso: false,
+ groupManagedAccount: false,
+ },
+];
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
index 59a8ca2ed23..27305abfafa 100644
--- a/spec/frontend/header_spec.js
+++ b/spec/frontend/header_spec.js
@@ -4,7 +4,7 @@ import initTodoToggle, { initNavUserDropdownTracking } from '~/header';
describe('Header', () => {
describe('Todos notification', () => {
- const todosPendingCount = '.todos-count';
+ const todosPendingCount = '.js-todos-count';
const fixtureTemplate = 'issues/open-issue.html';
function isTodosCountHidden() {
diff --git a/spec/frontend/helpers/dom_events_helper.js b/spec/frontend/helpers/dom_events_helper.js
index 139e0813397..423e5c58bb4 100644
--- a/spec/frontend/helpers/dom_events_helper.js
+++ b/spec/frontend/helpers/dom_events_helper.js
@@ -1,4 +1,3 @@
-// eslint-disable-next-line import/prefer-default-export
export const triggerDOMEvent = type => {
window.document.dispatchEvent(
new Event(type, {
diff --git a/spec/frontend/helpers/fake_request_animation_frame.js b/spec/frontend/helpers/fake_request_animation_frame.js
index b01ae5b7c5f..f6fc29df4dc 100644
--- a/spec/frontend/helpers/fake_request_animation_frame.js
+++ b/spec/frontend/helpers/fake_request_animation_frame.js
@@ -1,4 +1,3 @@
-// eslint-disable-next-line import/prefer-default-export
export const useFakeRequestAnimationFrame = () => {
let orig;
diff --git a/spec/frontend/helpers/jest_helpers.js b/spec/frontend/helpers/jest_helpers.js
index 4a150be9935..0b623e0a59b 100644
--- a/spec/frontend/helpers/jest_helpers.js
+++ b/spec/frontend/helpers/jest_helpers.js
@@ -1,5 +1,3 @@
-/* eslint-disable import/prefer-default-export */
-
/*
@module
diff --git a/spec/frontend/helpers/local_storage_helper.js b/spec/frontend/helpers/local_storage_helper.js
index a66c31d1353..cd39b660bfd 100644
--- a/spec/frontend/helpers/local_storage_helper.js
+++ b/spec/frontend/helpers/local_storage_helper.js
@@ -10,7 +10,7 @@
*/
const useLocalStorage = fn => {
const origLocalStorage = window.localStorage;
- let currentLocalStorage;
+ let currentLocalStorage = origLocalStorage;
Object.defineProperty(window, 'localStorage', {
get: () => currentLocalStorage,
diff --git a/spec/frontend/helpers/local_storage_helper_spec.js b/spec/frontend/helpers/local_storage_helper_spec.js
index 18aec0f329a..6b44ea3a4c3 100644
--- a/spec/frontend/helpers/local_storage_helper_spec.js
+++ b/spec/frontend/helpers/local_storage_helper_spec.js
@@ -1,8 +1,15 @@
import { useLocalStorageSpy } from './local_storage_helper';
-useLocalStorageSpy();
+describe('block before helper is installed', () => {
+ it('should leave original localStorage intact', () => {
+ expect(localStorage.getItem).toEqual(expect.any(Function));
+ expect(jest.isMockFunction(localStorage.getItem)).toBe(false);
+ });
+});
describe('localStorage helper', () => {
+ useLocalStorageSpy();
+
it('mocks localStorage but works exactly like original localStorage', () => {
localStorage.setItem('test', 'testing');
localStorage.setItem('test2', 'testing');
diff --git a/spec/frontend/helpers/locale_helper.js b/spec/frontend/helpers/locale_helper.js
index 80047b06003..283d9bc14c9 100644
--- a/spec/frontend/helpers/locale_helper.js
+++ b/spec/frontend/helpers/locale_helper.js
@@ -1,5 +1,3 @@
-/* eslint-disable import/prefer-default-export */
-
export const setLanguage = languageCode => {
const htmlElement = document.querySelector('html');
diff --git a/spec/frontend/helpers/mock_apollo_helper.js b/spec/frontend/helpers/mock_apollo_helper.js
new file mode 100644
index 00000000000..8a5a160231c
--- /dev/null
+++ b/spec/frontend/helpers/mock_apollo_helper.js
@@ -0,0 +1,23 @@
+import { InMemoryCache } from 'apollo-cache-inmemory';
+import { createMockClient } from 'mock-apollo-client';
+import VueApollo from 'vue-apollo';
+
+export default (handlers = []) => {
+ const fragmentMatcher = { match: () => true };
+ const cache = new InMemoryCache({
+ fragmentMatcher,
+ addTypename: false,
+ });
+
+ const mockClient = createMockClient({ cache });
+
+ if (Array.isArray(handlers)) {
+ handlers.forEach(([query, value]) => mockClient.setRequestHandler(query, value));
+ } else {
+ throw new Error('You should pass an array of handlers to mock Apollo client');
+ }
+
+ const apolloProvider = new VueApollo({ defaultClient: mockClient });
+
+ return apolloProvider;
+};
diff --git a/spec/frontend/helpers/mock_dom_observer.js b/spec/frontend/helpers/mock_dom_observer.js
index 7aac51f6264..1b93b81535d 100644
--- a/spec/frontend/helpers/mock_dom_observer.js
+++ b/spec/frontend/helpers/mock_dom_observer.js
@@ -84,7 +84,9 @@ const useMockObserver = (key, createMock) => {
mockObserver.$_triggerObserve(...args);
};
- return { trigger };
+ const observersCount = () => mockObserver.$_observers.length;
+
+ return { trigger, observersCount };
};
export const useMockIntersectionObserver = () =>
diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js
new file mode 100644
index 00000000000..7b83f0aefca
--- /dev/null
+++ b/spec/frontend/helpers/startup_css_helper_spec.js
@@ -0,0 +1,65 @@
+import { waitForCSSLoaded } from '../../../app/assets/javascripts/helpers/startup_css_helper';
+
+describe('waitForCSSLoaded', () => {
+ let mockedCallback;
+
+ beforeEach(() => {
+ mockedCallback = jest.fn();
+ });
+
+ describe('Promise-like api', () => {
+ it('can be used with a callback', async () => {
+ await waitForCSSLoaded(mockedCallback);
+ expect(mockedCallback).toHaveBeenCalledTimes(1);
+ });
+
+ it('can be used as a promise', async () => {
+ await waitForCSSLoaded().then(mockedCallback);
+ expect(mockedCallback).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('with startup css disabled', () => {
+ gon.features = {
+ startupCss: false,
+ };
+
+ it('should invoke the action right away', async () => {
+ const events = waitForCSSLoaded(mockedCallback);
+ await events;
+
+ expect(mockedCallback).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('with startup css enabled', () => {
+ gon.features = {
+ startupCss: true,
+ };
+
+ it('should dispatch CSSLoaded when the assets are cached or already loaded', async () => {
+ setFixtures(`
+ <link href="one.css" data-startupcss="loaded">
+ <link href="two.css" data-startupcss="loaded">
+ `);
+ await waitForCSSLoaded(mockedCallback);
+
+ expect(mockedCallback).toHaveBeenCalledTimes(1);
+ });
+
+ it('should wait to call CssLoaded until the assets are loaded', async () => {
+ setFixtures(`
+ <link href="one.css" data-startupcss="loading">
+ <link href="two.css" data-startupcss="loading">
+ `);
+ const events = waitForCSSLoaded(mockedCallback);
+ document
+ .querySelectorAll('[data-startupcss="loading"]')
+ .forEach(elem => elem.setAttribute('data-startupcss', 'loaded'));
+ document.dispatchEvent(new CustomEvent('CSSStartupLinkLoaded'));
+ await events;
+
+ expect(mockedCallback).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/spec/frontend/ide/commit_icon_spec.js b/spec/frontend/ide/commit_icon_spec.js
index e4a7394b089..0dfcae00298 100644
--- a/spec/frontend/ide/commit_icon_spec.js
+++ b/spec/frontend/ide/commit_icon_spec.js
@@ -7,7 +7,6 @@ const createFile = (name = 'name', id = name, type = '', parent = null) =>
id,
type,
icon: 'icon',
- url: 'url',
name,
path: parent ? `${parent.path}/${name}` : name,
parentPath: parent ? parent.path : '',
diff --git a/spec/frontend/ide/components/branches/item_spec.js b/spec/frontend/ide/components/branches/item_spec.js
index d8175025755..f1aa9187a8d 100644
--- a/spec/frontend/ide/components/branches/item_spec.js
+++ b/spec/frontend/ide/components/branches/item_spec.js
@@ -1,8 +1,8 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import { createStore } from '~/ide/stores';
import { createRouter } from '~/ide/ide_router';
import Item from '~/ide/components/branches/item.vue';
-import Icon from '~/vue_shared/components/icon.vue';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import { projectData } from '../../mock_data';
@@ -45,7 +45,7 @@ describe('IDE branch item', () => {
it('renders branch name and timeago', () => {
expect(wrapper.text()).toContain(TEST_BRANCH.name);
expect(wrapper.find(Timeago).props('time')).toBe(TEST_BRANCH.committedDate);
- expect(wrapper.find(Icon).exists()).toBe(false);
+ expect(wrapper.find(GlIcon).exists()).toBe(false);
});
it('renders link to branch', () => {
@@ -60,6 +60,6 @@ describe('IDE branch item', () => {
it('renders icon if is not active', () => {
createComponent({ isActive: true });
- expect(wrapper.find(Icon).exists()).toBe(true);
+ expect(wrapper.find(GlIcon).exists()).toBe(true);
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js
index 9245cefc183..56667d6b03d 100644
--- a/spec/frontend/ide/components/commit_sidebar/form_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js
@@ -1,10 +1,13 @@
import Vue from 'vue';
+import { getByText } from '@testing-library/dom';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import { projectData } from 'jest/ide/mock_data';
import waitForPromises from 'helpers/wait_for_promises';
import { createStore } from '~/ide/stores';
+import consts from '~/ide/stores/modules/commit/constants';
import CommitForm from '~/ide/components/commit_sidebar/form.vue';
import { leftSidebarViews } from '~/ide/constants';
+import { createCodeownersCommitError, createUnexpectedCommitError } from '~/ide/lib/errors';
describe('IDE commit form', () => {
const Component = Vue.extend(CommitForm);
@@ -259,21 +262,47 @@ describe('IDE commit form', () => {
});
});
- it('opens new branch modal if commitChanges throws an error', () => {
- vm.commitChanges.mockRejectedValue({ success: false });
+ it.each`
+ createError | props
+ ${() => createCodeownersCommitError('test message')} | ${{ actionPrimary: { text: 'Create new branch' } }}
+ ${createUnexpectedCommitError} | ${{ actionPrimary: null }}
+ `('opens error modal if commitError with $error', async ({ createError, props }) => {
+ jest.spyOn(vm.$refs.commitErrorModal, 'show');
- jest.spyOn(vm.$refs.createBranchModal, 'show').mockImplementation();
+ const error = createError();
+ store.state.commit.commitError = error;
- return vm
- .$nextTick()
- .then(() => {
- vm.$el.querySelector('.btn-success').click();
+ await vm.$nextTick();
- return vm.$nextTick();
- })
- .then(() => {
- expect(vm.$refs.createBranchModal.show).toHaveBeenCalled();
- });
+ expect(vm.$refs.commitErrorModal.show).toHaveBeenCalled();
+ expect(vm.$refs.commitErrorModal).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(document.body).toHaveText(error.messageHTML);
+ });
+ });
+
+ describe('with error modal with primary', () => {
+ beforeEach(() => {
+ jest.spyOn(vm.$store, 'dispatch').mockReturnValue(Promise.resolve());
+ });
+
+ it('updates commit action and commits', async () => {
+ store.state.commit.commitError = createCodeownersCommitError('test message');
+
+ await vm.$nextTick();
+
+ getByText(document.body, 'Create new branch').click();
+
+ await waitForPromises();
+
+ expect(vm.$store.dispatch.mock.calls).toEqual([
+ ['commit/updateCommitAction', consts.COMMIT_TO_NEW_BRANCH],
+ ['commit/commitChanges', undefined],
+ ]);
});
});
});
diff --git a/spec/frontend/ide/components/error_message_spec.js b/spec/frontend/ide/components/error_message_spec.js
index 3a4dcc5873d..8b7e7da3b51 100644
--- a/spec/frontend/ide/components/error_message_spec.js
+++ b/spec/frontend/ide/components/error_message_spec.js
@@ -51,7 +51,7 @@ describe('IDE error message component', () => {
createComponent();
findDismissButton().trigger('click');
- expect(setErrorMessageMock).toHaveBeenCalledWith(expect.any(Object), null, undefined);
+ expect(setErrorMessageMock).toHaveBeenCalledWith(expect.any(Object), null);
});
describe('with action', () => {
diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js
index 4bd27d23f76..2a106ad37c0 100644
--- a/spec/frontend/ide/components/file_row_extra_spec.js
+++ b/spec/frontend/ide/components/file_row_extra_spec.js
@@ -153,14 +153,14 @@ describe('IDE extra file row component', () => {
describe('merge request icon', () => {
it('hides when not a merge request change', () => {
- expect(vm.$el.querySelector('.ic-git-merge')).toBe(null);
+ expect(vm.$el.querySelector('[data-testid="git-merge-icon"]')).toBe(null);
});
it('shows when a merge request change', done => {
vm.file.mrChange = true;
vm.$nextTick(() => {
- expect(vm.$el.querySelector('.ic-git-merge')).not.toBe(null);
+ expect(vm.$el.querySelector('[data-testid="git-merge-icon"]')).not.toBe(null);
done();
});
diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js
index fed61233e55..bb8165d1a52 100644
--- a/spec/frontend/ide/components/ide_status_list_spec.js
+++ b/spec/frontend/ide/components/ide_status_list_spec.js
@@ -75,7 +75,8 @@ describe('ide/components/ide_status_list', () => {
describe('with binary file', () => {
beforeEach(() => {
- activeFile.binary = true;
+ activeFile.name = 'abc.dat';
+ activeFile.content = '🐱'; // non-ascii binary content
createComponent();
});
diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap
index dbfacb98813..a65d9e6f78b 100644
--- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap
+++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap
@@ -34,7 +34,7 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = `
</span>
</div>
- <icon-stub
+ <gl-icon-stub
class="ide-stage-collapse-icon"
name="angle-down"
size="16"
diff --git a/spec/frontend/ide/components/jobs/detail/description_spec.js b/spec/frontend/ide/components/jobs/detail/description_spec.js
index babae00d2f7..5554738336a 100644
--- a/spec/frontend/ide/components/jobs/detail/description_spec.js
+++ b/spec/frontend/ide/components/jobs/detail/description_spec.js
@@ -23,6 +23,16 @@ describe('IDE job description', () => {
});
it('renders CI icon', () => {
- expect(vm.$el.querySelector('.ci-status-icon .ic-status_success_borderless')).not.toBe(null);
+ expect(
+ vm.$el.querySelector('.ci-status-icon [data-testid="status_success_borderless-icon"]'),
+ ).not.toBe(null);
+ });
+
+ it('renders bridge job details without the job link', () => {
+ vm = mountComponent(Component, {
+ job: { ...jobs[0], path: undefined },
+ });
+
+ expect(vm.$el.querySelector('[data-testid="description-detail-link"]')).toBe(null);
});
});
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 b8dbca97ade..42526590ebb 100644
--- a/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js
+++ b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import Icon from '~/vue_shared/components/icon.vue';
+import { GlIcon } from '@gitlab/ui';
import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue';
describe('IDE job log scroll button', () => {
@@ -27,7 +27,7 @@ describe('IDE job log scroll button', () => {
beforeEach(() => createComponent({ direction }));
it('returns proper icon name', () => {
- expect(wrapper.find(Icon).props('name')).toBe(icon);
+ expect(wrapper.find(GlIcon).props('name')).toBe(icon);
});
it('returns proper title', () => {
diff --git a/spec/frontend/ide/components/jobs/detail_spec.js b/spec/frontend/ide/components/jobs/detail_spec.js
index acd30dee718..496d8284fdd 100644
--- a/spec/frontend/ide/components/jobs/detail_spec.js
+++ b/spec/frontend/ide/components/jobs/detail_spec.js
@@ -24,7 +24,7 @@ describe('IDE jobs detail view', () => {
beforeEach(() => {
vm = createComponent();
- jest.spyOn(vm, 'fetchJobTrace').mockResolvedValue();
+ jest.spyOn(vm, 'fetchJobLogs').mockResolvedValue();
});
afterEach(() => {
@@ -36,8 +36,8 @@ describe('IDE jobs detail view', () => {
vm = vm.$mount();
});
- it('calls fetchJobTrace', () => {
- expect(vm.fetchJobTrace).toHaveBeenCalled();
+ it('calls fetchJobLogs', () => {
+ expect(vm.fetchJobLogs).toHaveBeenCalled();
});
it('scrolls to bottom', () => {
@@ -96,7 +96,7 @@ describe('IDE jobs detail view', () => {
describe('scroll buttons', () => {
beforeEach(() => {
vm = createComponent();
- jest.spyOn(vm, 'fetchJobTrace').mockResolvedValue();
+ jest.spyOn(vm, 'fetchJobLogs').mockResolvedValue();
});
afterEach(() => {
diff --git a/spec/frontend/ide/components/jobs/item_spec.js b/spec/frontend/ide/components/jobs/item_spec.js
index 2f97d39e98e..93c01640b54 100644
--- a/spec/frontend/ide/components/jobs/item_spec.js
+++ b/spec/frontend/ide/components/jobs/item_spec.js
@@ -24,7 +24,7 @@ describe('IDE jobs item', () => {
});
it('renders CI icon', () => {
- expect(vm.$el.querySelector('.ic-status_success_borderless')).not.toBe(null);
+ expect(vm.$el.querySelector('[data-testid="status_success_borderless-icon"]')).not.toBe(null);
});
it('does not render view logs button if not started', done => {
diff --git a/spec/frontend/ide/components/jobs/list_spec.js b/spec/frontend/ide/components/jobs/list_spec.js
index d8880fa7cb7..e821a585e18 100644
--- a/spec/frontend/ide/components/jobs/list_spec.js
+++ b/spec/frontend/ide/components/jobs/list_spec.js
@@ -99,11 +99,7 @@ describe('IDE stages list', () => {
it('calls toggleStageCollapsed when clicking stage header', () => {
findCardHeader().trigger('click');
- expect(storeActions.toggleStageCollapsed).toHaveBeenCalledWith(
- expect.any(Object),
- 0,
- undefined,
- );
+ expect(storeActions.toggleStageCollapsed).toHaveBeenCalledWith(expect.any(Object), 0);
});
it('calls fetchJobs when stage is mounted', () => {
diff --git a/spec/frontend/ide/components/merge_requests/item_spec.js b/spec/frontend/ide/components/merge_requests/item_spec.js
index b1da89d7a9b..20adaa7abbc 100644
--- a/spec/frontend/ide/components/merge_requests/item_spec.js
+++ b/spec/frontend/ide/components/merge_requests/item_spec.js
@@ -33,7 +33,7 @@ describe('IDE merge request item', () => {
store,
});
};
- const findIcon = () => wrapper.find('.ic-mobile-issue-close');
+ const findIcon = () => wrapper.find('[data-testid="mobile-issue-close-icon"]');
beforeEach(() => {
store = createStore();
diff --git a/spec/frontend/ide/components/merge_requests/list_spec.js b/spec/frontend/ide/components/merge_requests/list_spec.js
index e2c6ac49e07..80dcd861451 100644
--- a/spec/frontend/ide/components/merge_requests/list_spec.js
+++ b/spec/frontend/ide/components/merge_requests/list_spec.js
@@ -56,14 +56,10 @@ describe('IDE merge requests list', () => {
it('calls fetch on mounted', () => {
createComponent();
- expect(fetchMergeRequestsMock).toHaveBeenCalledWith(
- expect.any(Object),
- {
- search: '',
- type: '',
- },
- undefined,
- );
+ expect(fetchMergeRequestsMock).toHaveBeenCalledWith(expect.any(Object), {
+ search: '',
+ type: '',
+ });
});
it('renders loading icon when merge request is loading', () => {
@@ -95,14 +91,10 @@ describe('IDE merge requests list', () => {
const searchType = wrapper.vm.$options.searchTypes[0];
expect(findTokenedInput().props('tokens')).toEqual([searchType]);
- expect(fetchMergeRequestsMock).toHaveBeenCalledWith(
- expect.any(Object),
- {
- type: searchType.type,
- search: '',
- },
- undefined,
- );
+ expect(fetchMergeRequestsMock).toHaveBeenCalledWith(expect.any(Object), {
+ type: searchType.type,
+ search: '',
+ });
});
});
@@ -136,14 +128,10 @@ describe('IDE merge requests list', () => {
input.vm.$emit('input', 'something');
return wrapper.vm.$nextTick().then(() => {
- expect(fetchMergeRequestsMock).toHaveBeenCalledWith(
- expect.any(Object),
- {
- search: 'something',
- type: '',
- },
- undefined,
- );
+ expect(fetchMergeRequestsMock).toHaveBeenCalledWith(expect.any(Object), {
+ search: 'something',
+ type: '',
+ });
});
});
});
diff --git a/spec/frontend/ide/components/nav_dropdown_button_spec.js b/spec/frontend/ide/components/nav_dropdown_button_spec.js
index 2aa3992a6d8..c98aa313f40 100644
--- a/spec/frontend/ide/components/nav_dropdown_button_spec.js
+++ b/spec/frontend/ide/components/nav_dropdown_button_spec.js
@@ -23,7 +23,7 @@ describe('NavDropdown', () => {
vm.$mount();
};
- const findIcon = name => vm.$el.querySelector(`.ic-${name}`);
+ const findIcon = name => vm.$el.querySelector(`[data-testid="${name}-icon"]`);
const findMRIcon = () => findIcon('merge-request');
const findBranchIcon = () => findIcon('branch');
diff --git a/spec/frontend/ide/components/nav_dropdown_spec.js b/spec/frontend/ide/components/nav_dropdown_spec.js
index ce123d925c8..2f91ab7af0a 100644
--- a/spec/frontend/ide/components/nav_dropdown_spec.js
+++ b/spec/frontend/ide/components/nav_dropdown_spec.js
@@ -39,7 +39,7 @@ describe('IDE NavDropdown', () => {
});
};
- const findIcon = name => wrapper.find(`.ic-${name}`);
+ const findIcon = name => wrapper.find(`[data-testid="${name}-icon"]`);
const findMRIcon = () => findIcon('merge-request');
const findNavForm = () => wrapper.find('.ide-nav-form');
const showDropdown = () => {
diff --git a/spec/frontend/ide/components/new_dropdown/button_spec.js b/spec/frontend/ide/components/new_dropdown/button_spec.js
index 3c611b7de8f..66317296ee9 100644
--- a/spec/frontend/ide/components/new_dropdown/button_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/button_spec.js
@@ -28,7 +28,7 @@ describe('IDE new entry dropdown button component', () => {
});
it('renders icon', () => {
- expect(vm.$el.querySelector('.ic-doc-new')).not.toBe(null);
+ expect(vm.$el.querySelector('[data-testid="doc-new-icon"]')).not.toBe(null);
});
it('emits click event', () => {
diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js
index ad27954cd10..ae497106f73 100644
--- a/spec/frontend/ide/components/new_dropdown/upload_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js
@@ -85,7 +85,6 @@ describe('new dropdown upload', () => {
name: textFile.name,
type: 'blob',
content: 'plain text',
- binary: false,
rawPath: '',
});
})
@@ -102,7 +101,6 @@ describe('new dropdown upload', () => {
name: binaryFile.name,
type: 'blob',
content: binaryTarget.result.split('base64,')[1],
- binary: true,
rawPath: binaryTarget.result,
});
});
diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js
index 86cdbafaff9..7f083fa7c25 100644
--- a/spec/frontend/ide/components/pipelines/list_spec.js
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -22,11 +22,11 @@ describe('IDE pipelines list', () => {
const defaultState = {
links: { ciHelpPagePath: TEST_HOST },
pipelinesEmptyStateSvgPath: TEST_HOST,
- pipelines: {
- stages: [],
- failedStages: [],
- isLoadingJobs: false,
- },
+ };
+ const defaultPipelinesState = {
+ stages: [],
+ failedStages: [],
+ isLoadingJobs: false,
};
const fetchLatestPipelineMock = jest.fn();
@@ -34,23 +34,20 @@ describe('IDE pipelines list', () => {
const failedStagesGetterMock = jest.fn().mockReturnValue([]);
const fakeProjectPath = 'alpha/beta';
- const createComponent = (state = {}) => {
- const { pipelines: pipelinesState, ...restOfState } = state;
- const { defaultPipelines, ...defaultRestOfState } = defaultState;
-
- const fakeStore = new Vuex.Store({
+ const createStore = (rootState, pipelinesState) => {
+ return new Vuex.Store({
getters: {
currentProject: () => ({ web_url: 'some/url ', path_with_namespace: fakeProjectPath }),
},
state: {
- ...defaultRestOfState,
- ...restOfState,
+ ...defaultState,
+ ...rootState,
},
modules: {
pipelines: {
namespaced: true,
state: {
- ...defaultPipelines,
+ ...defaultPipelinesState,
...pipelinesState,
},
actions: {
@@ -69,10 +66,12 @@ describe('IDE pipelines list', () => {
},
},
});
+ };
+ const createComponent = (state = {}, pipelinesState = {}) => {
wrapper = shallowMount(List, {
localVue,
- store: fakeStore,
+ store: createStore(state, pipelinesState),
});
};
@@ -94,31 +93,33 @@ describe('IDE pipelines list', () => {
describe('when loading', () => {
let defaultPipelinesLoadingState;
+
beforeAll(() => {
defaultPipelinesLoadingState = {
- ...defaultState.pipelines,
isLoadingPipeline: true,
};
});
it('does not render when pipeline has loaded before', () => {
- createComponent({
- pipelines: {
+ createComponent(
+ {},
+ {
...defaultPipelinesLoadingState,
hasLoadedPipeline: true,
},
- });
+ );
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('renders loading state', () => {
- createComponent({
- pipelines: {
+ createComponent(
+ {},
+ {
...defaultPipelinesLoadingState,
hasLoadedPipeline: false,
},
- });
+ );
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
@@ -126,21 +127,22 @@ describe('IDE pipelines list', () => {
describe('when loaded', () => {
let defaultPipelinesLoadedState;
+
beforeAll(() => {
defaultPipelinesLoadedState = {
- ...defaultState.pipelines,
isLoadingPipeline: false,
hasLoadedPipeline: true,
};
});
it('renders empty state when no latestPipeline', () => {
- createComponent({ pipelines: { ...defaultPipelinesLoadedState, latestPipeline: null } });
+ createComponent({}, { ...defaultPipelinesLoadedState, latestPipeline: null });
expect(wrapper.element).toMatchSnapshot();
});
describe('with latest pipeline loaded', () => {
let withLatestPipelineState;
+
beforeAll(() => {
withLatestPipelineState = {
...defaultPipelinesLoadedState,
@@ -149,12 +151,12 @@ describe('IDE pipelines list', () => {
});
it('renders ci icon', () => {
- createComponent({ pipelines: withLatestPipelineState });
+ createComponent({}, withLatestPipelineState);
expect(wrapper.find(CiIcon).exists()).toBe(true);
});
it('renders pipeline data', () => {
- createComponent({ pipelines: withLatestPipelineState });
+ createComponent({}, withLatestPipelineState);
expect(wrapper.text()).toContain('#1');
});
@@ -162,7 +164,7 @@ describe('IDE pipelines list', () => {
it('renders list of jobs', () => {
const stages = [];
const isLoadingJobs = true;
- createComponent({ pipelines: { ...withLatestPipelineState, stages, isLoadingJobs } });
+ createComponent({}, { ...withLatestPipelineState, stages, isLoadingJobs });
const jobProps = wrapper
.findAll(Tab)
@@ -177,7 +179,7 @@ describe('IDE pipelines list', () => {
const failedStages = [];
failedStagesGetterMock.mockReset().mockReturnValue(failedStages);
const isLoadingJobs = true;
- createComponent({ pipelines: { ...withLatestPipelineState, isLoadingJobs } });
+ createComponent({}, { ...withLatestPipelineState, isLoadingJobs });
const jobProps = wrapper
.findAll(Tab)
@@ -191,12 +193,13 @@ describe('IDE pipelines list', () => {
describe('with YAML error', () => {
it('renders YAML error', () => {
const yamlError = 'test yaml error';
- createComponent({
- pipelines: {
+ createComponent(
+ {},
+ {
...defaultPipelinesLoadedState,
latestPipeline: { ...pipelines[0], yamlError },
},
- });
+ );
expect(wrapper.text()).toContain('Found errors in your .gitlab-ci.yml:');
expect(wrapper.text()).toContain(yamlError);
diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js
index 7b2025f5e9f..7b22f75cee4 100644
--- a/spec/frontend/ide/components/preview/clientside_spec.js
+++ b/spec/frontend/ide/components/preview/clientside_spec.js
@@ -279,24 +279,16 @@ describe('IDE clientside preview', () => {
});
it('calls getFileData', () => {
- expect(storeActions.getFileData).toHaveBeenCalledWith(
- expect.any(Object),
- {
- path: 'package.json',
- makeFileActive: false,
- },
- undefined, // vuex callback
- );
+ expect(storeActions.getFileData).toHaveBeenCalledWith(expect.any(Object), {
+ path: 'package.json',
+ makeFileActive: false,
+ });
});
it('calls getRawFileData', () => {
- expect(storeActions.getRawFileData).toHaveBeenCalledWith(
- expect.any(Object),
- {
- path: 'package.json',
- },
- undefined, // vuex callback
- );
+ expect(storeActions.getRawFileData).toHaveBeenCalledWith(expect.any(Object), {
+ path: 'package.json',
+ });
});
});
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index f0ae2ba732b..9f4c9c1622a 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -45,7 +45,7 @@ describe('RepoEditor', () => {
const createOpenFile = path => {
const origFile = store.state.openFiles[0];
- const newFile = { ...origFile, path, key: path };
+ const newFile = { ...origFile, path, key: path, name: 'myfile.txt', content: 'hello world' };
store.state.entries[path] = newFile;
@@ -54,8 +54,9 @@ describe('RepoEditor', () => {
beforeEach(() => {
const f = {
- ...file(),
+ ...file('file.txt'),
viewMode: FILE_VIEW_MODE_EDITOR,
+ content: 'hello world',
};
const storeOptions = createStoreOptions();
@@ -142,6 +143,7 @@ describe('RepoEditor', () => {
...vm.file,
projectId: 'namespace/project',
path: 'sample.md',
+ name: 'sample.md',
content: 'testing 123',
});
@@ -200,7 +202,8 @@ describe('RepoEditor', () => {
describe('when open file is binary and not raw', () => {
beforeEach(done => {
- vm.file.binary = true;
+ vm.file.name = 'file.dat';
+ vm.file.content = '🐱'; // non-ascii binary content
vm.$nextTick(done);
});
@@ -407,6 +410,9 @@ describe('RepoEditor', () => {
beforeEach(done => {
jest.spyOn(vm.editor, 'updateDimensions').mockImplementation();
vm.file.viewMode = FILE_VIEW_MODE_PREVIEW;
+ vm.file.name = 'myfile.md';
+ vm.file.content = 'hello world';
+
vm.$nextTick(done);
});
@@ -650,7 +656,6 @@ describe('RepoEditor', () => {
path: 'foo/foo.png',
type: 'blob',
content: 'Zm9v',
- binary: true,
rawPath: 'data:image/png;base64,Zm9v',
});
});
diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js
index 5a591d3dcd0..f35726de27c 100644
--- a/spec/frontend/ide/components/repo_tab_spec.js
+++ b/spec/frontend/ide/components/repo_tab_spec.js
@@ -1,21 +1,24 @@
-import Vue from 'vue';
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
import { createStore } from '~/ide/stores';
-import repoTab from '~/ide/components/repo_tab.vue';
+import RepoTab from '~/ide/components/repo_tab.vue';
import { createRouter } from '~/ide/ide_router';
import { file } from '../helpers';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('RepoTab', () => {
- let vm;
+ let wrapper;
let store;
let router;
function createComponent(propsData) {
- const RepoTab = Vue.extend(repoTab);
-
- return new RepoTab({
+ wrapper = mount(RepoTab, {
+ localVue,
store,
propsData,
- }).$mount();
+ });
}
beforeEach(() => {
@@ -25,23 +28,24 @@ describe('RepoTab', () => {
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ wrapper = null;
});
it('renders a close link and a name link', () => {
- vm = createComponent({
+ createComponent({
tab: file(),
});
- vm.$store.state.openFiles.push(vm.tab);
- const close = vm.$el.querySelector('.multi-file-tab-close');
- const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`);
+ wrapper.vm.$store.state.openFiles.push(wrapper.vm.tab);
+ const close = wrapper.find('.multi-file-tab-close');
+ const name = wrapper.find(`[title]`);
- expect(close.innerHTML).toContain('#close');
- expect(name.textContent.trim()).toEqual(vm.tab.name);
+ expect(close.html()).toContain('#close');
+ expect(name.text().trim()).toEqual(wrapper.vm.tab.name);
});
- it('does not call openPendingTab when tab is active', done => {
- vm = createComponent({
+ it('does not call openPendingTab when tab is active', async () => {
+ createComponent({
tab: {
...file(),
pending: true,
@@ -49,63 +53,51 @@ describe('RepoTab', () => {
},
});
- jest.spyOn(vm, 'openPendingTab').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm, 'openPendingTab').mockImplementation(() => {});
- vm.$el.click();
+ await wrapper.trigger('click');
- vm.$nextTick(() => {
- expect(vm.openPendingTab).not.toHaveBeenCalled();
-
- done();
- });
+ expect(wrapper.vm.openPendingTab).not.toHaveBeenCalled();
});
it('fires clickFile when the link is clicked', () => {
- vm = createComponent({
+ createComponent({
tab: file(),
});
- jest.spyOn(vm, 'clickFile').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm, 'clickFile').mockImplementation(() => {});
- vm.$el.click();
+ wrapper.trigger('click');
- expect(vm.clickFile).toHaveBeenCalledWith(vm.tab);
+ expect(wrapper.vm.clickFile).toHaveBeenCalledWith(wrapper.vm.tab);
});
it('calls closeFile when clicking close button', () => {
- vm = createComponent({
+ createComponent({
tab: file(),
});
- jest.spyOn(vm, 'closeFile').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm, 'closeFile').mockImplementation(() => {});
- vm.$el.querySelector('.multi-file-tab-close').click();
+ wrapper.find('.multi-file-tab-close').trigger('click');
- expect(vm.closeFile).toHaveBeenCalledWith(vm.tab);
+ expect(wrapper.vm.closeFile).toHaveBeenCalledWith(wrapper.vm.tab);
});
- it('changes icon on hover', done => {
+ it('changes icon on hover', async () => {
const tab = file();
tab.changed = true;
- vm = createComponent({
+ createComponent({
tab,
});
- vm.$el.dispatchEvent(new Event('mouseover'));
+ await wrapper.trigger('mouseover');
- Vue.nextTick()
- .then(() => {
- expect(vm.$el.querySelector('.file-modified')).toBeNull();
+ expect(wrapper.find('.file-modified').exists()).toBe(false);
- vm.$el.dispatchEvent(new Event('mouseout'));
- })
- .then(Vue.nextTick)
- .then(() => {
- expect(vm.$el.querySelector('.file-modified')).not.toBeNull();
+ await wrapper.trigger('mouseout');
- done();
- })
- .catch(done.fail);
+ expect(wrapper.find('.file-modified').exists()).toBe(true);
});
describe('locked file', () => {
@@ -120,21 +112,17 @@ describe('RepoTab', () => {
},
};
- vm = createComponent({
+ createComponent({
tab: f,
});
});
- afterEach(() => {
- vm.$destroy();
- });
-
it('renders lock icon', () => {
- expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
+ expect(wrapper.find('.file-status-icon')).not.toBeNull();
});
it('renders a tooltip', () => {
- expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain(
+ expect(wrapper.find('span:nth-child(2)').attributes('data-original-title')).toContain(
'Locked by testuser',
);
});
@@ -142,45 +130,37 @@ describe('RepoTab', () => {
describe('methods', () => {
describe('closeTab', () => {
- it('closes tab if file has changed', done => {
+ it('closes tab if file has changed', async () => {
const tab = file();
tab.changed = true;
tab.opened = true;
- vm = createComponent({
+ createComponent({
tab,
});
- vm.$store.state.openFiles.push(tab);
- vm.$store.state.changedFiles.push(tab);
- vm.$store.state.entries[tab.path] = tab;
- vm.$store.dispatch('setFileActive', tab.path);
-
- vm.$el.querySelector('.multi-file-tab-close').click();
+ 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);
- vm.$nextTick(() => {
- expect(tab.opened).toBeFalsy();
- expect(vm.$store.state.changedFiles.length).toBe(1);
+ await wrapper.find('.multi-file-tab-close').trigger('click');
- done();
- });
+ expect(tab.opened).toBeFalsy();
+ expect(wrapper.vm.$store.state.changedFiles).toHaveLength(1);
});
- it('closes tab when clicking close btn', done => {
+ it('closes tab when clicking close btn', async () => {
const tab = file('lose');
tab.opened = true;
- vm = createComponent({
+ createComponent({
tab,
});
- vm.$store.state.openFiles.push(tab);
- vm.$store.state.entries[tab.path] = tab;
- vm.$store.dispatch('setFileActive', tab.path);
+ wrapper.vm.$store.state.openFiles.push(tab);
+ wrapper.vm.$store.state.entries[tab.path] = tab;
+ wrapper.vm.$store.dispatch('setFileActive', tab.path);
- vm.$el.querySelector('.multi-file-tab-close').click();
+ await wrapper.find('.multi-file-tab-close').trigger('click');
- vm.$nextTick(() => {
- expect(tab.opened).toBeFalsy();
-
- done();
- });
+ expect(tab.opened).toBeFalsy();
});
});
});
diff --git a/spec/frontend/ide/components/repo_tabs_spec.js b/spec/frontend/ide/components/repo_tabs_spec.js
index df5b01770f5..b251f207853 100644
--- a/spec/frontend/ide/components/repo_tabs_spec.js
+++ b/spec/frontend/ide/components/repo_tabs_spec.js
@@ -1,27 +1,40 @@
-import Vue from 'vue';
-import repoTabs from '~/ide/components/repo_tabs.vue';
-import createComponent from '../../helpers/vue_mount_component_helper';
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { createStore } from '~/ide/stores';
+import RepoTabs from '~/ide/components/repo_tabs.vue';
import { file } from '../helpers';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('RepoTabs', () => {
- const openedFiles = [file('open1'), file('open2')];
- const RepoTabs = Vue.extend(repoTabs);
- let vm;
+ let wrapper;
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.openFiles = [file('open1'), file('open2')];
+
+ wrapper = mount(RepoTabs, {
+ propsData: {
+ files: store.state.openFiles,
+ viewer: 'editor',
+ activeFile: file('activeFile'),
+ },
+ store,
+ localVue,
+ });
+ });
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('renders a list of tabs', done => {
- vm = createComponent(RepoTabs, {
- files: openedFiles,
- viewer: 'editor',
- activeFile: file('activeFile'),
- });
- openedFiles[0].active = true;
+ store.state.openFiles[0].active = true;
- vm.$nextTick(() => {
- const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')];
+ wrapper.vm.$nextTick(() => {
+ const tabs = [...wrapper.vm.$el.querySelectorAll('.multi-file-tab')];
expect(tabs.length).toEqual(2);
expect(tabs[0].parentNode.classList.contains('active')).toEqual(true);
diff --git a/spec/frontend/ide/components/terminal/session_spec.js b/spec/frontend/ide/components/terminal/session_spec.js
index 2399446ed15..ce61a31691a 100644
--- a/spec/frontend/ide/components/terminal/session_spec.js
+++ b/spec/frontend/ide/components/terminal/session_spec.js
@@ -52,7 +52,7 @@ describe('IDE TerminalSession', () => {
state.session = null;
factory();
- expect(wrapper.isEmpty()).toBe(true);
+ expect(wrapper.html()).toBe('');
});
it('shows terminal', () => {
diff --git a/spec/frontend/ide/components/terminal/terminal_controls_spec.js b/spec/frontend/ide/components/terminal/terminal_controls_spec.js
index 6c2871abb46..c22063e1d72 100644
--- a/spec/frontend/ide/components/terminal/terminal_controls_spec.js
+++ b/spec/frontend/ide/components/terminal/terminal_controls_spec.js
@@ -42,24 +42,24 @@ describe('IDE TerminalControls', () => {
it('emits "scroll-up" when click up button', () => {
factory({ propsData: { canScrollUp: true } });
- expect(wrapper.emittedByOrder()).toEqual([]);
+ expect(wrapper.emitted()).toEqual({});
buttons.at(0).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emittedByOrder()).toEqual([{ name: 'scroll-up', args: [] }]);
+ expect(wrapper.emitted('scroll-up')).toEqual([[]]);
});
});
it('emits "scroll-down" when click down button', () => {
factory({ propsData: { canScrollDown: true } });
- expect(wrapper.emittedByOrder()).toEqual([]);
+ expect(wrapper.emitted()).toEqual({});
buttons.at(1).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emittedByOrder()).toEqual([{ name: 'scroll-down', args: [] }]);
+ expect(wrapper.emitted('scroll-down')).toEqual([[]]);
});
});
});
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 16a76fae1dd..9adf5848f9d 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
@@ -1,13 +1,12 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import TerminalSyncStatus from '~/ide/components/terminal_sync/terminal_sync_status.vue';
import {
MSG_TERMINAL_SYNC_CONNECTING,
MSG_TERMINAL_SYNC_UPLOADING,
MSG_TERMINAL_SYNC_RUNNING,
} from '~/ide/stores/modules/terminal_sync/messages';
-import Icon from '~/vue_shared/components/icon.vue';
const TEST_MESSAGE = 'lorem ipsum dolar sit';
const START_LOADING = 'START_LOADING';
@@ -58,7 +57,7 @@ describe('ide/components/terminal_sync/terminal_sync_status', () => {
it('shows nothing', () => {
createComponent();
- expect(wrapper.isEmpty()).toBe(true);
+ expect(wrapper.html()).toBe('');
});
});
@@ -80,7 +79,7 @@ describe('ide/components/terminal_sync/terminal_sync_status', () => {
if (!icon) {
it('does not render icon', () => {
- expect(wrapper.find(Icon).exists()).toBe(false);
+ expect(wrapper.find(GlIcon).exists()).toBe(false);
});
it('renders loading icon', () => {
@@ -88,7 +87,7 @@ describe('ide/components/terminal_sync/terminal_sync_status', () => {
});
} else {
it('renders icon', () => {
- expect(wrapper.find(Icon).props('name')).toEqual(icon);
+ expect(wrapper.find(GlIcon).props('name')).toEqual(icon);
});
it('does not render loading icon', () => {
diff --git a/spec/frontend/ide/helpers.js b/spec/frontend/ide/helpers.js
index 8caa9c2b437..0e85b523cbd 100644
--- a/spec/frontend/ide/helpers.js
+++ b/spec/frontend/ide/helpers.js
@@ -6,7 +6,6 @@ export const file = (name = 'name', id = name, type = '', parent = null) =>
id,
type,
icon: 'icon',
- url: 'url',
name,
path: parent ? `${parent.path}/${name}` : name,
parentPath: parent ? parent.path : '',
diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js
index 529f80e6f6f..01c2eab33a5 100644
--- a/spec/frontend/ide/lib/editor_spec.js
+++ b/spec/frontend/ide/lib/editor_spec.js
@@ -202,28 +202,6 @@ describe('Multi-file editor library', () => {
});
});
- describe('schemas', () => {
- let originalGon;
-
- beforeEach(() => {
- originalGon = window.gon;
- window.gon = { features: { schemaLinting: true } };
-
- delete Editor.editorInstance;
- instance = Editor.create();
- });
-
- afterEach(() => {
- window.gon = originalGon;
- });
-
- it('registers custom schemas defined with Monaco', () => {
- expect(monacoLanguages.yaml.yamlDefaults.diagnosticsOptions).toMatchObject({
- schemas: [{ fileMatch: ['*.gitlab-ci.yml'] }],
- });
- });
- });
-
describe('replaceSelectedText', () => {
let model;
let editor;
diff --git a/spec/frontend/ide/lib/errors_spec.js b/spec/frontend/ide/lib/errors_spec.js
new file mode 100644
index 00000000000..8c3fb378302
--- /dev/null
+++ b/spec/frontend/ide/lib/errors_spec.js
@@ -0,0 +1,70 @@
+import {
+ createUnexpectedCommitError,
+ createCodeownersCommitError,
+ createBranchChangedCommitError,
+ parseCommitError,
+} from '~/ide/lib/errors';
+
+const TEST_SPECIAL = '&special<';
+const TEST_SPECIAL_ESCAPED = '&amp;special&lt;';
+const TEST_MESSAGE = 'Test message.';
+const CODEOWNERS_MESSAGE =
+ 'Push to protected branches that contain changes to files matching CODEOWNERS is not allowed';
+const CHANGED_MESSAGE = 'Things changed since you started editing';
+
+describe('~/ide/lib/errors', () => {
+ const createResponseError = message => ({
+ response: {
+ data: {
+ message,
+ },
+ },
+ });
+
+ describe('createCodeownersCommitError', () => {
+ it('uses given message', () => {
+ expect(createCodeownersCommitError(TEST_MESSAGE)).toEqual({
+ title: 'CODEOWNERS rule violation',
+ messageHTML: TEST_MESSAGE,
+ canCreateBranch: true,
+ });
+ });
+
+ it('escapes special chars', () => {
+ expect(createCodeownersCommitError(TEST_SPECIAL)).toEqual({
+ title: 'CODEOWNERS rule violation',
+ messageHTML: TEST_SPECIAL_ESCAPED,
+ canCreateBranch: true,
+ });
+ });
+ });
+
+ describe('createBranchChangedCommitError', () => {
+ it.each`
+ message | expectedMessage
+ ${TEST_MESSAGE} | ${`${TEST_MESSAGE}<br/><br/>Would you like to create a new branch?`}
+ ${TEST_SPECIAL} | ${`${TEST_SPECIAL_ESCAPED}<br/><br/>Would you like to create a new branch?`}
+ `('uses given message="$message"', ({ message, expectedMessage }) => {
+ expect(createBranchChangedCommitError(message)).toEqual({
+ title: 'Branch changed',
+ messageHTML: expectedMessage,
+ canCreateBranch: true,
+ });
+ });
+ });
+
+ describe('parseCommitError', () => {
+ it.each`
+ message | expectation
+ ${null} | ${createUnexpectedCommitError()}
+ ${{}} | ${createUnexpectedCommitError()}
+ ${{ response: {} }} | ${createUnexpectedCommitError()}
+ ${{ response: { data: {} } }} | ${createUnexpectedCommitError()}
+ ${createResponseError('test')} | ${createUnexpectedCommitError()}
+ ${createResponseError(CODEOWNERS_MESSAGE)} | ${createCodeownersCommitError(CODEOWNERS_MESSAGE)}
+ ${createResponseError(CHANGED_MESSAGE)} | ${createBranchChangedCommitError(CHANGED_MESSAGE)}
+ `('parses message into error object with "$message"', ({ message, expectation }) => {
+ expect(parseCommitError(message)).toEqual(expectation);
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/files_spec.js b/spec/frontend/ide/lib/files_spec.js
index 6974cdc4074..8ca6f01d9a6 100644
--- a/spec/frontend/ide/lib/files_spec.js
+++ b/spec/frontend/ide/lib/files_spec.js
@@ -1,29 +1,16 @@
-import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import { decorateFiles, splitParent } from '~/ide/lib/files';
import { decorateData } from '~/ide/stores/utils';
-const TEST_BRANCH_ID = 'lorem-ipsum';
-const TEST_PROJECT_ID = 10;
-
const createEntries = paths => {
const createEntry = (acc, { path, type, children }) => {
- // Sometimes we need to end the url with a '/'
- const createUrl = base => (type === 'tree' ? `${base}/` : base);
-
const { name, parent } = splitParent(path);
- const previewMode = viewerInformationForPath(name);
acc[path] = {
...decorateData({
- projectId: TEST_PROJECT_ID,
- branchId: TEST_BRANCH_ID,
id: path,
name,
path,
- url: createUrl(`/${TEST_PROJECT_ID}/${type}/${TEST_BRANCH_ID}/-/${path}`),
type,
- previewMode,
- binary: (previewMode && previewMode.binary) || false,
parentPath: parent,
}),
tree: children.map(childName => expect.objectContaining({ name: childName })),
@@ -56,11 +43,7 @@ describe('IDE lib decorate files', () => {
{ path: 'README.md', type: 'blob', children: [] },
]);
- const { entries, treeList } = decorateFiles({
- data,
- branchId: TEST_BRANCH_ID,
- projectId: TEST_PROJECT_ID,
- });
+ const { entries, treeList } = decorateFiles({ data });
// Here we test the keys and then each key/value individually because `expect(entries).toEqual(expectedEntries)`
// was taking a very long time for some reason. Probably due to large objects and nested `expect.objectContaining`.
diff --git a/spec/frontend/ide/mock_data.js b/spec/frontend/ide/mock_data.js
index 472516b6a2c..c8925e6745d 100644
--- a/spec/frontend/ide/mock_data.js
+++ b/spec/frontend/ide/mock_data.js
@@ -112,7 +112,8 @@ export const jobs = [
{
id: 4,
name: 'test 4',
- path: 'testing4',
+ // bridge jobs don't have details page and so there is no path attribute
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/216480
status: {
icon: 'status_failed',
text: 'failed',
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index bc3f86702cf..d2c32a81811 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -146,7 +146,7 @@ describe('IDE services', () => {
it('gives back file.baseRaw for files with that property present', () => {
file.baseRaw = TEST_FILE_CONTENTS;
- return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => {
+ return services.getBaseRawFileData(file, TEST_PROJECT_ID, TEST_COMMIT_SHA).then(content => {
expect(content).toEqual(TEST_FILE_CONTENTS);
});
});
@@ -155,7 +155,7 @@ describe('IDE services', () => {
file.tempFile = true;
file.baseRaw = TEST_FILE_CONTENTS;
- return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => {
+ return services.getBaseRawFileData(file, TEST_PROJECT_ID, TEST_COMMIT_SHA).then(content => {
expect(content).toEqual(TEST_FILE_CONTENTS);
});
});
@@ -192,7 +192,7 @@ describe('IDE services', () => {
});
it('fetches file content', () =>
- services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => {
+ services.getBaseRawFileData(file, TEST_PROJECT_ID, TEST_COMMIT_SHA).then(content => {
expect(content).toEqual(TEST_FILE_CONTENTS);
}));
},
diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js
index 88e7a9fff36..974c0715c06 100644
--- a/spec/frontend/ide/stores/actions/file_spec.js
+++ b/spec/frontend/ide/stores/actions/file_spec.js
@@ -27,6 +27,10 @@ describe('IDE store file actions', () => {
};
store = createStore();
+
+ store.state.currentProjectId = 'test/test';
+ store.state.currentBranchId = 'master';
+
router = createRouter(store);
jest.spyOn(store, 'commit');
@@ -72,10 +76,7 @@ describe('IDE store file actions', () => {
});
it('closes file & opens next available file', () => {
- const f = {
- ...file('newOpenFile'),
- url: '/newOpenFile',
- };
+ const f = file('newOpenFile');
store.state.openFiles.push(f);
store.state.entries[f.path] = f;
@@ -84,7 +85,7 @@ describe('IDE store file actions', () => {
.dispatch('closeFile', localFile)
.then(Vue.nextTick)
.then(() => {
- expect(router.push).toHaveBeenCalledWith(`/project${f.url}`);
+ expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/master/-/newOpenFile/');
});
});
@@ -240,7 +241,6 @@ describe('IDE store file actions', () => {
200,
{
raw_path: 'raw_path',
- binary: false,
},
{
'page-title': 'testing getFileData',
@@ -296,7 +296,6 @@ describe('IDE store file actions', () => {
describe('Re-named success', () => {
beforeEach(() => {
localFile = file(`newCreate-${Math.random()}`);
- localFile.url = `project/getFileDataURL`;
localFile.prevPath = 'old-dull-file';
localFile.path = 'new-shiny-file';
store.state.entries[localFile.path] = localFile;
@@ -305,7 +304,6 @@ describe('IDE store file actions', () => {
200,
{
raw_path: 'raw_path',
- binary: false,
},
{
'page-title': 'testing old-dull-file',
@@ -393,7 +391,11 @@ describe('IDE store file actions', () => {
tmpFile.mrChange = { new_file: false };
return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => {
- expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA');
+ expect(service.getBaseRawFileData).toHaveBeenCalledWith(
+ tmpFile,
+ 'gitlab-org/gitlab-ce',
+ 'SHA',
+ );
expect(tmpFile.baseRaw).toBe('baseraw');
});
});
@@ -660,7 +662,7 @@ describe('IDE store file actions', () => {
});
it('pushes route for active file', () => {
- expect(router.push).toHaveBeenCalledWith(`/project${tmpFile.url}`);
+ expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/master/-/tempFile/');
});
});
});
@@ -735,10 +737,8 @@ describe('IDE store file actions', () => {
});
it('pushes router URL when added', () => {
- store.state.currentBranchId = 'master';
-
return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(() => {
- expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/');
+ expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/master/');
});
});
});
diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js
index 62971b9cad6..b1cceda9d85 100644
--- a/spec/frontend/ide/stores/actions/merge_request_spec.js
+++ b/spec/frontend/ide/stores/actions/merge_request_spec.js
@@ -453,11 +453,9 @@ describe('IDE store merge request actions', () => {
it('updates activity bar view and gets file data, if changes are found', done => {
store.state.entries.foo = {
- url: 'test',
type: 'blob',
};
store.state.entries.bar = {
- url: 'test',
type: 'blob',
};
diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js
index f77dbd80025..ebf39df2f6f 100644
--- a/spec/frontend/ide/stores/actions_spec.js
+++ b/spec/frontend/ide/stores/actions_spec.js
@@ -123,7 +123,6 @@ describe('Multi-file store actions', () => {
it('creates temp tree', done => {
store
.dispatch('createTempEntry', {
- branchId: store.state.currentBranchId,
name: 'test',
type: 'tree',
})
@@ -150,7 +149,6 @@ describe('Multi-file store actions', () => {
store
.dispatch('createTempEntry', {
- branchId: store.state.currentBranchId,
name: 'testing/test',
type: 'tree',
})
@@ -176,7 +174,6 @@ describe('Multi-file store actions', () => {
store
.dispatch('createTempEntry', {
- branchId: store.state.currentBranchId,
name: 'testing',
type: 'tree',
})
@@ -197,7 +194,6 @@ describe('Multi-file store actions', () => {
store
.dispatch('createTempEntry', {
name,
- branchId: 'mybranch',
type: 'blob',
})
.then(() => {
@@ -217,7 +213,6 @@ describe('Multi-file store actions', () => {
store
.dispatch('createTempEntry', {
name,
- branchId: 'mybranch',
type: 'blob',
})
.then(() => {
@@ -237,7 +232,6 @@ describe('Multi-file store actions', () => {
store
.dispatch('createTempEntry', {
name,
- branchId: 'mybranch',
type: 'blob',
})
.then(() => {
@@ -249,7 +243,7 @@ describe('Multi-file store actions', () => {
});
it('sets tmp file as active', () => {
- createTempEntry(store, { name: 'test', branchId: 'mybranch', type: 'blob' });
+ createTempEntry(store, { name: 'test', type: 'blob' });
expect(store.dispatch).toHaveBeenCalledWith('setFileActive', 'test');
});
@@ -262,7 +256,6 @@ describe('Multi-file store actions', () => {
store
.dispatch('createTempEntry', {
name: 'test',
- branchId: 'mybranch',
type: 'blob',
})
.then(() => {
@@ -780,9 +773,11 @@ describe('Multi-file store actions', () => {
});
it('routes to the renamed file if the original file has been opened', done => {
+ store.state.currentProjectId = 'test/test';
+ store.state.currentBranchId = 'master';
+
Object.assign(store.state.entries.orig, {
opened: true,
- url: '/foo-bar.md',
});
store
@@ -792,7 +787,7 @@ describe('Multi-file store actions', () => {
})
.then(() => {
expect(router.push.mock.calls).toHaveLength(1);
- expect(router.push).toHaveBeenCalledWith(`/project/foo-bar.md`);
+ expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/master/-/renamed/`);
})
.then(done)
.catch(done.fail);
diff --git a/spec/frontend/ide/stores/getters_spec.js b/spec/frontend/ide/stores/getters_spec.js
index dcf05329ce0..e24f08fa802 100644
--- a/spec/frontend/ide/stores/getters_spec.js
+++ b/spec/frontend/ide/stores/getters_spec.js
@@ -1,3 +1,4 @@
+import { TEST_HOST } from 'helpers/test_constants';
import * as getters from '~/ide/stores/getters';
import { createStore } from '~/ide/stores';
import { file } from '../helpers';
@@ -482,4 +483,48 @@ describe('IDE store getters', () => {
expect(localStore.getters.getAvailableFileName('foo-bar1.jpg')).toBe('foo-bar1.jpg');
});
});
+
+ describe('getUrlForPath', () => {
+ it('returns a route url for the given path', () => {
+ localState.currentProjectId = 'test/test';
+ localState.currentBranchId = 'master';
+
+ expect(localStore.getters.getUrlForPath('path/to/foo/bar-1.jpg')).toBe(
+ `/project/test/test/tree/master/-/path/to/foo/bar-1.jpg/`,
+ );
+ });
+ });
+
+ describe('getJsonSchemaForPath', () => {
+ beforeEach(() => {
+ localState.currentProjectId = 'path/to/some/project';
+ localState.currentBranchId = 'master';
+ });
+
+ it('returns a json schema uri and match config for a json/yaml file that can be loaded by monaco', () => {
+ expect(localStore.getters.getJsonSchemaForPath('.gitlab-ci.yml')).toEqual({
+ fileMatch: ['*.gitlab-ci.yml'],
+ uri: `${TEST_HOST}/path/to/some/project/-/schema/master/.gitlab-ci.yml`,
+ });
+ });
+
+ it('returns a path containing sha if branch details are present in state', () => {
+ localState.projects['path/to/some/project'] = {
+ name: 'project',
+ branches: {
+ master: {
+ name: 'master',
+ commit: {
+ id: 'abcdef123456',
+ },
+ },
+ },
+ };
+
+ expect(localStore.getters.getJsonSchemaForPath('.gitlab-ci.yml')).toEqual({
+ fileMatch: ['*.gitlab-ci.yml'],
+ uri: `${TEST_HOST}/path/to/some/project/-/schema/abcdef123456/.gitlab-ci.yml`,
+ });
+ });
+ });
});
diff --git a/spec/frontend/ide/stores/integration_spec.js b/spec/frontend/ide/stores/integration_spec.js
index f95f036f572..b6a7c7fd02d 100644
--- a/spec/frontend/ide/stores/integration_spec.js
+++ b/spec/frontend/ide/stores/integration_spec.js
@@ -36,8 +36,6 @@ describe('IDE store integration', () => {
beforeEach(() => {
const { entries, treeList } = decorateFiles({
data: [`${TEST_PATH_DIR}/`, TEST_PATH, 'README.md'],
- projectId: TEST_PROJECT_ID,
- branchId: TEST_BRANCH,
});
Object.assign(entries[TEST_PATH], {
diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js
index a14879112fd..babc50e54f1 100644
--- a/spec/frontend/ide/stores/modules/commit/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js
@@ -9,6 +9,7 @@ import eventHub from '~/ide/eventhub';
import consts from '~/ide/stores/modules/commit/constants';
import * as mutationTypes from '~/ide/stores/modules/commit/mutation_types';
import * as actions from '~/ide/stores/modules/commit/actions';
+import { createUnexpectedCommitError } from '~/ide/lib/errors';
import { commitActionTypes, PERMISSION_CREATE_MR } from '~/ide/constants';
import testAction from '../../../../helpers/vuex_action_helper';
@@ -510,7 +511,7 @@ describe('IDE commit module actions', () => {
});
});
- describe('failed', () => {
+ describe('success response with failed message', () => {
beforeEach(() => {
jest.spyOn(service, 'commit').mockResolvedValue({
data: {
@@ -533,6 +534,25 @@ describe('IDE commit module actions', () => {
});
});
+ describe('failed response', () => {
+ beforeEach(() => {
+ jest.spyOn(service, 'commit').mockRejectedValue({});
+ });
+
+ it('commits error updates', async () => {
+ jest.spyOn(store, 'commit');
+
+ await store.dispatch('commit/commitChanges').catch(() => {});
+
+ expect(store.commit.mock.calls).toEqual([
+ ['commit/CLEAR_ERROR', undefined, undefined],
+ ['commit/UPDATE_LOADING', true, undefined],
+ ['commit/UPDATE_LOADING', false, undefined],
+ ['commit/SET_ERROR', createUnexpectedCommitError(), undefined],
+ ]);
+ });
+ });
+
describe('first commit of a branch', () => {
const COMMIT_RESPONSE = {
id: '123456',
diff --git a/spec/frontend/ide/stores/modules/commit/mutations_spec.js b/spec/frontend/ide/stores/modules/commit/mutations_spec.js
index 45ac1a86ab3..6393a70eac6 100644
--- a/spec/frontend/ide/stores/modules/commit/mutations_spec.js
+++ b/spec/frontend/ide/stores/modules/commit/mutations_spec.js
@@ -1,5 +1,6 @@
import commitState from '~/ide/stores/modules/commit/state';
import mutations from '~/ide/stores/modules/commit/mutations';
+import * as types from '~/ide/stores/modules/commit/mutation_types';
describe('IDE commit module mutations', () => {
let state;
@@ -62,4 +63,24 @@ describe('IDE commit module mutations', () => {
expect(state.shouldCreateMR).toBe(false);
});
});
+
+ describe(types.CLEAR_ERROR, () => {
+ it('should clear commitError', () => {
+ state.commitError = {};
+
+ mutations[types.CLEAR_ERROR](state);
+
+ expect(state.commitError).toBeNull();
+ });
+ });
+
+ describe(types.SET_ERROR, () => {
+ it('should set commitError', () => {
+ const error = { title: 'foo' };
+
+ mutations[types.SET_ERROR](state, error);
+
+ expect(state.commitError).toBe(error);
+ });
+ });
});
diff --git a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js
index 71918e7e2c2..8511843cc92 100644
--- a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js
@@ -15,10 +15,10 @@ import {
fetchJobs,
toggleStageCollapsed,
setDetailJob,
- requestJobTrace,
- receiveJobTraceError,
- receiveJobTraceSuccess,
- fetchJobTrace,
+ requestJobLogs,
+ receiveJobLogsError,
+ receiveJobLogsSuccess,
+ fetchJobLogs,
resetLatestPipeline,
} from '~/ide/stores/modules/pipelines/actions';
import state from '~/ide/stores/modules/pipelines/state';
@@ -324,24 +324,24 @@ describe('IDE pipelines actions', () => {
});
});
- describe('requestJobTrace', () => {
+ describe('requestJobLogs', () => {
it('commits request', done => {
- testAction(requestJobTrace, null, mockedState, [{ type: types.REQUEST_JOB_TRACE }], [], done);
+ testAction(requestJobLogs, null, mockedState, [{ type: types.REQUEST_JOB_LOGS }], [], done);
});
});
- describe('receiveJobTraceError', () => {
+ describe('receiveJobLogsError', () => {
it('commits error', done => {
testAction(
- receiveJobTraceError,
+ receiveJobLogsError,
null,
mockedState,
- [{ type: types.RECEIVE_JOB_TRACE_ERROR }],
+ [{ type: types.RECEIVE_JOB_LOGS_ERROR }],
[
{
type: 'setErrorMessage',
payload: {
- text: 'An error occurred while fetching the job trace.',
+ text: 'An error occurred while fetching the job logs.',
action: expect.any(Function),
actionText: 'Please try again',
actionPayload: null,
@@ -353,20 +353,20 @@ describe('IDE pipelines actions', () => {
});
});
- describe('receiveJobTraceSuccess', () => {
+ describe('receiveJobLogsSuccess', () => {
it('commits data', done => {
testAction(
- receiveJobTraceSuccess,
+ receiveJobLogsSuccess,
'data',
mockedState,
- [{ type: types.RECEIVE_JOB_TRACE_SUCCESS, payload: 'data' }],
+ [{ type: types.RECEIVE_JOB_LOGS_SUCCESS, payload: 'data' }],
[],
done,
);
});
});
- describe('fetchJobTrace', () => {
+ describe('fetchJobLogs', () => {
beforeEach(() => {
mockedState.detailJob = { path: `${TEST_HOST}/project/builds` };
});
@@ -379,20 +379,20 @@ describe('IDE pipelines actions', () => {
it('dispatches request', done => {
testAction(
- fetchJobTrace,
+ fetchJobLogs,
null,
mockedState,
[],
[
- { type: 'requestJobTrace' },
- { type: 'receiveJobTraceSuccess', payload: { html: 'html' } },
+ { type: 'requestJobLogs' },
+ { type: 'receiveJobLogsSuccess', payload: { html: 'html' } },
],
done,
);
});
it('sends get request to correct URL', () => {
- fetchJobTrace({
+ fetchJobLogs({
state: mockedState,
dispatch() {},
@@ -410,11 +410,11 @@ describe('IDE pipelines actions', () => {
it('dispatches error', done => {
testAction(
- fetchJobTrace,
+ fetchJobLogs,
null,
mockedState,
[],
- [{ type: 'requestJobTrace' }, { type: 'receiveJobTraceError' }],
+ [{ type: 'requestJobLogs' }, { type: 'receiveJobLogsError' }],
done,
);
});
diff --git a/spec/frontend/ide/stores/modules/pipelines/mutations_spec.js b/spec/frontend/ide/stores/modules/pipelines/mutations_spec.js
index 3b7f92cfa74..7d2f5d5d710 100644
--- a/spec/frontend/ide/stores/modules/pipelines/mutations_spec.js
+++ b/spec/frontend/ide/stores/modules/pipelines/mutations_spec.js
@@ -175,37 +175,37 @@ describe('IDE pipelines mutations', () => {
});
});
- describe('REQUEST_JOB_TRACE', () => {
+ describe('REQUEST_JOB_LOGS', () => {
beforeEach(() => {
mockedState.detailJob = { ...jobs[0] };
});
it('sets loading on detail job', () => {
- mutations[types.REQUEST_JOB_TRACE](mockedState);
+ mutations[types.REQUEST_JOB_LOGS](mockedState);
expect(mockedState.detailJob.isLoading).toBe(true);
});
});
- describe('RECEIVE_JOB_TRACE_ERROR', () => {
+ describe('RECEIVE_JOB_LOGS_ERROR', () => {
beforeEach(() => {
mockedState.detailJob = { ...jobs[0], isLoading: true };
});
it('sets loading to false on detail job', () => {
- mutations[types.RECEIVE_JOB_TRACE_ERROR](mockedState);
+ mutations[types.RECEIVE_JOB_LOGS_ERROR](mockedState);
expect(mockedState.detailJob.isLoading).toBe(false);
});
});
- describe('RECEIVE_JOB_TRACE_SUCCESS', () => {
+ describe('RECEIVE_JOB_LOGS_SUCCESS', () => {
beforeEach(() => {
mockedState.detailJob = { ...jobs[0], isLoading: true };
});
it('sets output on detail job', () => {
- mutations[types.RECEIVE_JOB_TRACE_SUCCESS](mockedState, { html: 'html' });
+ mutations[types.RECEIVE_JOB_LOGS_SUCCESS](mockedState, { html: 'html' });
expect(mockedState.detailJob.output).toBe('html');
expect(mockedState.detailJob.isLoading).toBe(false);
});
diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js
index ff904bbc9cd..b53e40be980 100644
--- a/spec/frontend/ide/stores/mutations/file_spec.js
+++ b/spec/frontend/ide/stores/mutations/file_spec.js
@@ -61,13 +61,11 @@ describe('IDE store file mutations', () => {
mutations.SET_FILE_DATA(localState, {
data: {
raw_path: 'raw',
- binary: true,
},
file: localFile,
});
expect(localFile.rawPath).toBe('raw');
- expect(localFile.binary).toBeTruthy();
expect(localFile.raw).toBeNull();
expect(localFile.baseRaw).toBeNull();
});
diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js
index 1b29648fb8b..09e9481e5d4 100644
--- a/spec/frontend/ide/stores/mutations_spec.js
+++ b/spec/frontend/ide/stores/mutations_spec.js
@@ -113,8 +113,6 @@ describe('Multi-file store mutations', () => {
},
treeList: [tmpFile],
},
- projectId: 'gitlab-ce',
- branchId: 'master',
});
expect(localState.trees['gitlab-ce/master'].tree.length).toEqual(1);
@@ -272,7 +270,6 @@ describe('Multi-file store mutations', () => {
prevId: undefined,
prevPath: undefined,
prevName: undefined,
- prevUrl: undefined,
prevKey: undefined,
}),
);
@@ -337,7 +334,6 @@ describe('Multi-file store mutations', () => {
};
Object.assign(localState.entries['root-folder/oldPath'], {
parentPath: 'root-folder',
- url: 'root-folder/oldPath-blob-root-folder/oldPath',
});
mutations.RENAME_ENTRY(localState, {
@@ -366,9 +362,6 @@ describe('Multi-file store mutations', () => {
});
it('renames entry, preserving old parameters', () => {
- Object.assign(localState.entries.oldPath, {
- url: `project/-/oldPath`,
- });
const oldPathData = localState.entries.oldPath;
mutations.RENAME_ENTRY(localState, {
@@ -382,12 +375,10 @@ describe('Multi-file store mutations', () => {
id: 'newPath',
path: 'newPath',
name: 'newPath',
- url: `project/-/newPath`,
key: expect.stringMatching('newPath'),
prevId: 'oldPath',
prevName: 'oldPath',
prevPath: 'oldPath',
- prevUrl: `project/-/oldPath`,
prevKey: oldPathData.key,
prevParentPath: oldPathData.parentPath,
});
@@ -409,7 +400,6 @@ describe('Multi-file store mutations', () => {
prevId: expect.anything(),
prevName: expect.anything(),
prevPath: expect.anything(),
- prevUrl: expect.anything(),
prevKey: expect.anything(),
prevParentPath: expect.anything(),
}),
@@ -419,7 +409,7 @@ describe('Multi-file store mutations', () => {
it('properly handles files with spaces in name', () => {
const path = 'my fancy path';
const newPath = 'new path';
- const oldEntry = { ...file(path, path, 'blob'), url: `project/-/${path}` };
+ const oldEntry = file(path, path, 'blob');
localState.entries[path] = oldEntry;
@@ -435,12 +425,10 @@ describe('Multi-file store mutations', () => {
id: newPath,
path: newPath,
name: newPath,
- url: `project/-/new path`,
key: expect.stringMatching(newPath),
prevId: path,
prevName: path,
prevPath: path,
- prevUrl: `project/-/my fancy path`,
prevKey: oldEntry.key,
prevParentPath: oldEntry.parentPath,
});
@@ -549,7 +537,7 @@ describe('Multi-file store mutations', () => {
it('correctly saves original values if an entry is renamed multiple times', () => {
const original = { ...localState.entries.oldPath };
- const paramsToCheck = ['prevId', 'prevPath', 'prevName', 'prevUrl'];
+ const paramsToCheck = ['prevId', 'prevPath', 'prevName'];
const expectedObj = paramsToCheck.reduce(
(o, param) => ({ ...o, [param]: original[param.replace('prev', '').toLowerCase()] }),
{},
@@ -577,7 +565,6 @@ describe('Multi-file store mutations', () => {
prevId: 'lorem/orig',
prevPath: 'lorem/orig',
prevName: 'orig',
- prevUrl: 'project/-/loren/orig',
prevKey: 'lorem/orig',
prevParentPath: 'lorem',
};
@@ -602,7 +589,6 @@ describe('Multi-file store mutations', () => {
prevId: undefined,
prevPath: undefined,
prevName: undefined,
- prevUrl: undefined,
prevKey: undefined,
prevParentPath: undefined,
}),
diff --git a/spec/frontend/ide/sync_router_and_store_spec.js b/spec/frontend/ide/sync_router_and_store_spec.js
index ccf6e200806..20fd77c4dfb 100644
--- a/spec/frontend/ide/sync_router_and_store_spec.js
+++ b/spec/frontend/ide/sync_router_and_store_spec.js
@@ -17,9 +17,13 @@ describe('~/ide/sync_router_and_store', () => {
const getRouterCurrentPath = () => router.currentRoute.fullPath;
const getStoreCurrentPath = () => store.state.router.fullPath;
- const updateRouter = path => {
+ const updateRouter = async path => {
+ if (getRouterCurrentPath() === path) {
+ return;
+ }
+
router.push(path);
- return waitForPromises();
+ await waitForPromises();
};
const updateStore = path => {
store.dispatch('router/push', path);
diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js
index e7ef0de45a0..97dc8217ecc 100644
--- a/spec/frontend/ide/utils_spec.js
+++ b/spec/frontend/ide/utils_spec.js
@@ -2,7 +2,7 @@ import { languages } from 'monaco-editor';
import {
isTextFile,
registerLanguages,
- registerSchemas,
+ registerSchema,
trimPathComponents,
insertFinalNewline,
trimTrailingWhitespace,
@@ -13,60 +13,78 @@ import {
describe('WebIDE utils', () => {
describe('isTextFile', () => {
- it('returns false for known binary types', () => {
- expect(isTextFile('file content', 'image/png', 'my.png')).toBeFalsy();
- // mime types are case insensitive
- expect(isTextFile('file content', 'IMAGE/PNG', 'my.png')).toBeFalsy();
+ it.each`
+ mimeType | name | type | result
+ ${'image/png'} | ${'my.png'} | ${'binary'} | ${false}
+ ${'IMAGE/PNG'} | ${'my.png'} | ${'binary'} | ${false}
+ ${'text/plain'} | ${'my.txt'} | ${'text'} | ${true}
+ ${'TEXT/PLAIN'} | ${'my.txt'} | ${'text'} | ${true}
+ `('returns $result for known $type types', ({ mimeType, name, result }) => {
+ expect(isTextFile({ content: 'file content', mimeType, name })).toBe(result);
});
- it('returns true for known text types', () => {
- expect(isTextFile('file content', 'text/plain', 'my.txt')).toBeTruthy();
- // mime types are case insensitive
- expect(isTextFile('file content', 'TEXT/PLAIN', 'my.txt')).toBeTruthy();
- });
+ it.each`
+ content | mimeType | name
+ ${'{"éêė":"value"}'} | ${'application/json'} | ${'my.json'}
+ ${'{"éêė":"value"}'} | ${'application/json'} | ${'.tsconfig'}
+ ${'SELECT "éêė" from tablename'} | ${'application/sql'} | ${'my.sql'}
+ ${'{"éêė":"value"}'} | ${'application/json'} | ${'MY.JSON'}
+ ${'SELECT "éêė" from tablename'} | ${'application/sql'} | ${'MY.SQL'}
+ ${'var code = "something"'} | ${'application/javascript'} | ${'Gruntfile'}
+ ${'MAINTAINER Александр "a21283@me.com"'} | ${'application/octet-stream'} | ${'dockerfile'}
+ `(
+ 'returns true for file extensions that Monaco supports syntax highlighting for',
+ ({ content, mimeType, name }) => {
+ expect(isTextFile({ content, mimeType, name })).toBe(true);
+ },
+ );
- it('returns true for file extensions that Monaco supports syntax highlighting for', () => {
- // test based on both MIME and extension
- expect(isTextFile('{"éêė":"value"}', 'application/json', 'my.json')).toBeTruthy();
- expect(isTextFile('{"éêė":"value"}', 'application/json', '.tsconfig')).toBeTruthy();
- expect(isTextFile('SELECT "éêė" from tablename', 'application/sql', 'my.sql')).toBeTruthy();
+ it('returns false if filename is same as the expected extension', () => {
+ expect(
+ isTextFile({
+ name: 'sql',
+ content: 'SELECT "éêė" from tablename',
+ mimeType: 'application/sql',
+ }),
+ ).toBeFalsy();
});
- it('returns true even irrespective of whether the mimes, extensions or file names are lowercase or upper case', () => {
- expect(isTextFile('{"éêė":"value"}', 'application/json', 'MY.JSON')).toBeTruthy();
- expect(isTextFile('SELECT "éêė" from tablename', 'application/sql', 'MY.SQL')).toBeTruthy();
- expect(
- isTextFile('var code = "something"', 'application/javascript', 'Gruntfile'),
- ).toBeTruthy();
+ it('returns true for ASCII only content for unknown types', () => {
expect(
- isTextFile(
- 'MAINTAINER Александр "alexander11354322283@me.com"',
- 'application/octet-stream',
- 'dockerfile',
- ),
+ isTextFile({
+ name: 'hello.mytype',
+ content: 'plain text',
+ mimeType: 'application/x-new-type',
+ }),
).toBeTruthy();
});
- it('returns false if filename is same as the expected extension', () => {
- expect(isTextFile('SELECT "éêė" from tablename', 'application/sql', 'sql')).toBeFalsy();
- });
-
- it('returns true for ASCII only content for unknown types', () => {
- expect(isTextFile('plain text', 'application/x-new-type', 'hello.mytype')).toBeTruthy();
+ it('returns false for non-ASCII content for unknown types', () => {
+ expect(
+ isTextFile({
+ name: 'my.random',
+ content: '{"éêė":"value"}',
+ mimeType: 'application/octet-stream',
+ }),
+ ).toBeFalsy();
});
- it('returns true for relevant filenames', () => {
- expect(
- isTextFile(
- 'MAINTAINER Александр "alexander11354322283@me.com"',
- 'application/octet-stream',
- 'Dockerfile',
- ),
- ).toBeTruthy();
+ it.each`
+ name | result
+ ${'myfile.txt'} | ${true}
+ ${'Dockerfile'} | ${true}
+ ${'img.png'} | ${false}
+ ${'abc.js'} | ${true}
+ ${'abc.random'} | ${false}
+ ${'image.jpeg'} | ${false}
+ `('returns $result for $filename when no content or mimeType is passed', ({ name, result }) => {
+ expect(isTextFile({ name })).toBe(result);
});
- it('returns false for non-ASCII content for unknown types', () => {
- expect(isTextFile('{"éêė":"value"}', 'application/octet-stream', 'my.random')).toBeFalsy();
+ it('returns true if content is empty string but false if content is not passed', () => {
+ expect(isTextFile({ name: 'abc.dat' })).toBe(false);
+ expect(isTextFile({ name: 'abc.dat', content: '' })).toBe(true);
+ expect(isTextFile({ name: 'abc.dat', content: ' ' })).toBe(true);
});
});
@@ -159,55 +177,37 @@ describe('WebIDE utils', () => {
});
});
- describe('registerSchemas', () => {
- let options;
+ describe('registerSchema', () => {
+ let schema;
beforeEach(() => {
- options = {
- validate: true,
- enableSchemaRequest: true,
- hover: true,
- completion: true,
- schemas: [
- {
- uri: 'http://myserver/foo-schema.json',
- fileMatch: ['*'],
- schema: {
- id: 'http://myserver/foo-schema.json',
- type: 'object',
- properties: {
- p1: { enum: ['v1', 'v2'] },
- p2: { $ref: 'http://myserver/bar-schema.json' },
- },
- },
- },
- {
- uri: 'http://myserver/bar-schema.json',
- schema: {
- id: 'http://myserver/bar-schema.json',
- type: 'object',
- properties: { q1: { enum: ['x1', 'x2'] } },
- },
+ schema = {
+ uri: 'http://myserver/foo-schema.json',
+ fileMatch: ['*'],
+ schema: {
+ id: 'http://myserver/foo-schema.json',
+ type: 'object',
+ properties: {
+ p1: { enum: ['v1', 'v2'] },
+ p2: { $ref: 'http://myserver/bar-schema.json' },
},
- ],
+ },
};
jest.spyOn(languages.json.jsonDefaults, 'setDiagnosticsOptions');
jest.spyOn(languages.yaml.yamlDefaults, 'setDiagnosticsOptions');
});
- it.each`
- language | defaultsObj
- ${'json'} | ${languages.json.jsonDefaults}
- ${'yaml'} | ${languages.yaml.yamlDefaults}
- `(
- 'registers the given schemas with monaco for lang: $language',
- ({ language, defaultsObj }) => {
- registerSchemas({ language, options });
+ it('registers the given schemas with monaco for both json and yaml languages', () => {
+ registerSchema(schema);
- expect(defaultsObj.setDiagnosticsOptions).toHaveBeenCalledWith(options);
- },
- );
+ expect(languages.json.jsonDefaults.setDiagnosticsOptions).toHaveBeenCalledWith(
+ expect.objectContaining({ schemas: [schema] }),
+ );
+ expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledWith(
+ expect.objectContaining({ schemas: [schema] }),
+ );
+ });
});
describe('trimTrailingWhitespace', () => {
diff --git a/spec/frontend/import_projects/components/bitbucket_status_table_spec.js b/spec/frontend/import_projects/components/bitbucket_status_table_spec.js
index 132ccd0e324..b65b388fd5f 100644
--- a/spec/frontend/import_projects/components/bitbucket_status_table_spec.js
+++ b/spec/frontend/import_projects/components/bitbucket_status_table_spec.js
@@ -33,7 +33,7 @@ describe('BitbucketStatusTable', () => {
it('renders import table component', () => {
createComponent({ providerTitle: 'Test' });
- expect(wrapper.contains(ImportProjectsTable)).toBe(true);
+ expect(wrapper.find(ImportProjectsTable).exists()).toBe(true);
});
it('passes alert in incompatible-repos-warning slot', () => {
diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js
index b217242968a..1dbad588ec4 100644
--- a/spec/frontend/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_projects/components/import_projects_table_spec.js
@@ -1,15 +1,12 @@
import { nextTick } from 'vue';
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon, GlButton } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlIntersectionObserver } from '@gitlab/ui';
import state from '~/import_projects/store/state';
import * as getters from '~/import_projects/store/getters';
import { STATUSES } from '~/import_projects/constants';
import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue';
-import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue';
import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue';
-import IncompatibleRepoTableRow from '~/import_projects/components/incompatible_repo_table_row.vue';
-import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue';
describe('ImportProjectsTable', () => {
let wrapper;
@@ -18,16 +15,26 @@ describe('ImportProjectsTable', () => {
wrapper.find('input[data-qa-selector="githubish_import_filter_field"]');
const providerTitle = 'THE PROVIDER';
- const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' };
+ const providerRepo = {
+ importSource: {
+ id: 10,
+ sanitizedName: 'sanitizedName',
+ fullName: 'fullName',
+ },
+ importedProject: null,
+ };
const findImportAllButton = () =>
wrapper
.findAll(GlButton)
.filter(w => w.props().variant === 'success')
.at(0);
+ const findImportAllModal = () => wrapper.find({ ref: 'importAllModal' });
const importAllFn = jest.fn();
+ const importAllModalShowFn = jest.fn();
const setPageFn = jest.fn();
+ const fetchReposFn = jest.fn();
function createComponent({
state: initialState,
@@ -46,7 +53,7 @@ describe('ImportProjectsTable', () => {
...customGetters,
},
actions: {
- fetchRepos: jest.fn(),
+ fetchRepos: fetchReposFn,
fetchJobs: jest.fn(),
fetchNamespaces: jest.fn(),
importAll: importAllFn,
@@ -66,6 +73,9 @@ describe('ImportProjectsTable', () => {
paginatable,
},
slots,
+ stubs: {
+ GlModal: { template: '<div>Modal!</div>', methods: { show: importAllModalShowFn } },
+ },
});
}
@@ -79,58 +89,54 @@ describe('ImportProjectsTable', () => {
it('renders a loading icon while repos are loading', () => {
createComponent({ state: { isLoadingRepos: true } });
- expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('renders a loading icon while namespaces are loading', () => {
createComponent({ state: { isLoadingNamespaces: true } });
- expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
- it('renders a table with imported projects and provider repos', () => {
+ it('renders a table with provider repos', () => {
+ const repositories = [
+ { importSource: { id: 1 }, importedProject: null },
+ { importSource: { id: 2 }, importedProject: { importStatus: STATUSES.FINISHED } },
+ { importSource: { id: 3, incompatible: true }, importedProject: {} },
+ ];
+
createComponent({
- state: {
- namespaces: [{ fullPath: 'path' }],
- repositories: [
- { importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE },
- { importSource: { id: 2 }, importedProject: {}, importStatus: STATUSES.FINISHED },
- {
- importSource: { id: 3, incompatible: true },
- importedProject: {},
- importStatus: STATUSES.NONE,
- },
- ],
- },
+ state: { namespaces: [{ fullPath: 'path' }], repositories },
});
- expect(wrapper.contains(GlLoadingIcon)).toBe(false);
- expect(wrapper.contains('table')).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find('table').exists()).toBe(true);
expect(
wrapper
.findAll('th')
.filter(w => w.text() === `From ${providerTitle}`)
- .isEmpty(),
- ).toBe(false);
+ .exists(),
+ ).toBe(true);
- expect(wrapper.contains(ProviderRepoTableRow)).toBe(true);
- expect(wrapper.contains(ImportedProjectTableRow)).toBe(true);
- expect(wrapper.contains(IncompatibleRepoTableRow)).toBe(true);
+ expect(wrapper.findAll(ProviderRepoTableRow)).toHaveLength(repositories.length);
});
it.each`
- hasIncompatibleRepos | buttonText
- ${false} | ${'Import all repositories'}
- ${true} | ${'Import all compatible repositories'}
+ hasIncompatibleRepos | count | buttonText
+ ${false} | ${1} | ${'Import 1 repository'}
+ ${true} | ${1} | ${'Import 1 compatible repository'}
+ ${false} | ${5} | ${'Import 5 repositories'}
+ ${true} | ${5} | ${'Import 5 compatible repositories'}
`(
- 'import all button has "$buttonText" text when hasIncompatibleRepos is $hasIncompatibleRepos',
- ({ hasIncompatibleRepos, buttonText }) => {
+ 'import all button has "$buttonText" text when hasIncompatibleRepos is $hasIncompatibleRepos and repos count is $count',
+ ({ hasIncompatibleRepos, buttonText, count }) => {
createComponent({
state: {
providerRepos: [providerRepo],
},
getters: {
hasIncompatibleRepos: () => hasIncompatibleRepos,
+ importAllCount: () => count,
},
});
@@ -138,20 +144,28 @@ describe('ImportProjectsTable', () => {
},
);
- it('renders an empty state if there are no projects available', () => {
+ it('renders an empty state if there are no repositories available', () => {
createComponent({ state: { repositories: [] } });
- expect(wrapper.contains(ProviderRepoTableRow)).toBe(false);
- expect(wrapper.contains(ImportedProjectTableRow)).toBe(false);
+ expect(wrapper.find(ProviderRepoTableRow).exists()).toBe(false);
expect(wrapper.text()).toContain(`No ${providerTitle} repositories found`);
});
- it('sends importAll event when import button is clicked', async () => {
- createComponent({ state: { providerRepos: [providerRepo] } });
+ it('opens confirmation modal when import all button is clicked', async () => {
+ createComponent({ state: { repositories: [providerRepo] } });
findImportAllButton().vm.$emit('click');
await nextTick();
+ expect(importAllModalShowFn).toHaveBeenCalled();
+ });
+
+ it('triggers importAll action when modal is confirmed', async () => {
+ createComponent({ state: { providerRepos: [providerRepo] } });
+
+ findImportAllModal().vm.$emit('ok');
+ await nextTick();
+
expect(importAllFn).toHaveBeenCalled();
});
@@ -189,21 +203,29 @@ describe('ImportProjectsTable', () => {
});
});
- it('passes current page to page-query-param-sync component', () => {
- expect(wrapper.find(PageQueryParamSync).props().page).toBe(pageInfo.page);
+ it('does not call fetchRepos on mount', () => {
+ expect(fetchReposFn).not.toHaveBeenCalled();
+ });
+
+ it('renders intersection observer component', () => {
+ expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
});
- it('dispatches setPage when page-query-param-sync emits popstate', () => {
- const NEW_PAGE = 2;
- wrapper.find(PageQueryParamSync).vm.$emit('popstate', NEW_PAGE);
+ it('calls fetchRepos when intersection observer appears', async () => {
+ wrapper.find(GlIntersectionObserver).vm.$emit('appear');
- const { calls } = setPageFn.mock;
+ await nextTick();
- expect(calls).toHaveLength(1);
- expect(calls[0][1]).toBe(NEW_PAGE);
+ expect(fetchReposFn).toHaveBeenCalled();
});
});
+ it('calls fetchRepos on mount', () => {
+ createComponent();
+
+ expect(fetchReposFn).toHaveBeenCalled();
+ });
+
it.each`
hasIncompatibleRepos | shouldRenderSlot | action
${false} | ${false} | ${'does not render'}
diff --git a/spec/frontend/import_projects/components/imported_project_table_row_spec.js b/spec/frontend/import_projects/components/imported_project_table_row_spec.js
deleted file mode 100644
index 8890c352826..00000000000
--- a/spec/frontend/import_projects/components/imported_project_table_row_spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import { mount } from '@vue/test-utils';
-import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue';
-import ImportStatus from '~/import_projects/components/import_status.vue';
-import { STATUSES } from '~/import_projects/constants';
-
-describe('ImportedProjectTableRow', () => {
- let wrapper;
- const project = {
- importSource: {
- fullName: 'fullName',
- providerLink: 'providerLink',
- },
- importedProject: {
- id: 1,
- fullPath: 'fullPath',
- importSource: 'importSource',
- },
- importStatus: STATUSES.FINISHED,
- };
-
- function mountComponent() {
- wrapper = mount(ImportedProjectTableRow, { propsData: { project } });
- }
-
- beforeEach(() => {
- mountComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders an imported project table row', () => {
- const providerLink = wrapper.find('[data-testid=providerLink]');
-
- expect(providerLink.attributes().href).toMatch(project.importSource.providerLink);
- expect(providerLink.text()).toMatch(project.importSource.fullName);
- expect(wrapper.find('[data-testid=fullPath]').text()).toMatch(project.importedProject.fullPath);
- expect(wrapper.find(ImportStatus).props().status).toBe(project.importStatus);
- expect(wrapper.find('[data-testid=goToProject').attributes().href).toMatch(
- project.importedProject.fullPath,
- );
- });
-});
diff --git a/spec/frontend/import_projects/components/page_query_param_sync_spec.js b/spec/frontend/import_projects/components/page_query_param_sync_spec.js
deleted file mode 100644
index be19ecca1ba..00000000000
--- a/spec/frontend/import_projects/components/page_query_param_sync_spec.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { TEST_HOST } from 'helpers/test_constants';
-
-import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue';
-
-describe('PageQueryParamSync', () => {
- let originalPushState;
- let originalAddEventListener;
- let originalRemoveEventListener;
-
- const pushStateMock = jest.fn();
- const addEventListenerMock = jest.fn();
- const removeEventListenerMock = jest.fn();
-
- beforeAll(() => {
- window.location.search = '';
- originalPushState = window.pushState;
-
- window.history.pushState = pushStateMock;
-
- originalAddEventListener = window.addEventListener;
- window.addEventListener = addEventListenerMock;
-
- originalRemoveEventListener = window.removeEventListener;
- window.removeEventListener = removeEventListenerMock;
- });
-
- afterAll(() => {
- window.history.pushState = originalPushState;
- window.addEventListener = originalAddEventListener;
- window.removeEventListener = originalRemoveEventListener;
- });
-
- let wrapper;
- beforeEach(() => {
- wrapper = shallowMount(PageQueryParamSync, {
- propsData: { page: 3 },
- });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('calls push state with page number when page is updated and differs from 1', async () => {
- wrapper.setProps({ page: 2 });
-
- await nextTick();
-
- const { calls } = pushStateMock.mock;
- expect(calls).toHaveLength(1);
- expect(calls[0][2]).toBe(`${TEST_HOST}/?page=2`);
- });
-
- it('calls push state without page number when page is updated and is 1', async () => {
- wrapper.setProps({ page: 1 });
-
- await nextTick();
-
- const { calls } = pushStateMock.mock;
- expect(calls).toHaveLength(1);
- expect(calls[0][2]).toBe(`${TEST_HOST}/`);
- });
-
- it('subscribes to popstate event on create', () => {
- expect(addEventListenerMock).toHaveBeenCalledWith('popstate', expect.any(Function));
- });
-
- it('unsubscribes from popstate event when destroyed', () => {
- const [, fn] = addEventListenerMock.mock.calls[0];
-
- wrapper.destroy();
-
- expect(removeEventListenerMock).toHaveBeenCalledWith('popstate', fn);
- });
-
- it('emits popstate event when popstate is triggered', async () => {
- const [, fn] = addEventListenerMock.mock.calls[0];
-
- delete window.location;
- window.location = new URL(`${TEST_HOST}/?page=5`);
- fn();
-
- expect(wrapper.emitted().popstate[0]).toStrictEqual([5]);
- });
-});
diff --git a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js
index bd9cd07db78..03e30ef610e 100644
--- a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js
+++ b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js
@@ -1,6 +1,7 @@
import { nextTick } from 'vue';
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlBadge } from '@gitlab/ui';
import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue';
import ImportStatus from '~/import_projects/components/import_status.vue';
import { STATUSES } from '~/import_projects/constants';
@@ -14,20 +15,6 @@ describe('ProviderRepoTableRow', () => {
targetNamespace: 'target',
newName: 'newName',
};
- const ciCdOnly = false;
- const repo = {
- importSource: {
- id: 'remote-1',
- fullName: 'fullName',
- providerLink: 'providerLink',
- },
- importedProject: {
- id: 1,
- fullPath: 'fullPath',
- importSource: 'importSource',
- },
- importStatus: STATUSES.FINISHED,
- };
const availableNamespaces = [
{ text: 'Groups', children: [{ id: 'test', text: 'test' }] },
@@ -46,55 +33,137 @@ describe('ProviderRepoTableRow', () => {
return store;
}
- const findImportButton = () =>
- wrapper
- .findAll('button')
- .filter(node => node.text() === 'Import')
- .at(0);
+ const findImportButton = () => {
+ const buttons = wrapper.findAll('button').filter(node => node.text() === 'Import');
+
+ return buttons.length ? buttons.at(0) : buttons;
+ };
- function mountComponent(initialState) {
+ function mountComponent(props) {
const localVue = createLocalVue();
localVue.use(Vuex);
- const store = initStore({ ciCdOnly, ...initialState });
+ const store = initStore();
wrapper = shallowMount(ProviderRepoTableRow, {
localVue,
store,
- propsData: { repo, availableNamespaces },
+ propsData: { availableNamespaces, ...props },
});
}
- beforeEach(() => {
- mountComponent();
- });
-
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
});
- it('renders a provider repo table row', () => {
- const providerLink = wrapper.find('[data-testid=providerLink]');
+ describe('when rendering importable project', () => {
+ const repo = {
+ importSource: {
+ id: 'remote-1',
+ fullName: 'fullName',
+ providerLink: 'providerLink',
+ },
+ };
+
+ beforeEach(() => {
+ mountComponent({ repo });
+ });
+
+ it('renders project information', () => {
+ const providerLink = wrapper.find('[data-testid=providerLink]');
+
+ expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink);
+ expect(providerLink.text()).toMatch(repo.importSource.fullName);
+ });
+
+ it('renders empty import status', () => {
+ expect(wrapper.find(ImportStatus).props().status).toBe(STATUSES.NONE);
+ });
+
+ it('renders a select2 namespace select', () => {
+ expect(wrapper.find(Select2Select).exists()).toBe(true);
+ expect(wrapper.find(Select2Select).props().options.data).toBe(availableNamespaces);
+ });
+
+ it('renders import button', () => {
+ expect(findImportButton().exists()).toBe(true);
+ });
+
+ it('imports repo when clicking import button', async () => {
+ findImportButton().trigger('click');
+
+ await nextTick();
+
+ const { calls } = fetchImport.mock;
- expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink);
- expect(providerLink.text()).toMatch(repo.importSource.fullName);
- expect(wrapper.find(ImportStatus).props().status).toBe(repo.importStatus);
- expect(wrapper.contains('button')).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0][1]).toBe(repo.importSource.id);
+ });
});
- it('renders a select2 namespace select', () => {
- expect(wrapper.contains(Select2Select)).toBe(true);
- expect(wrapper.find(Select2Select).props().options.data).toBe(availableNamespaces);
+ describe('when rendering imported project', () => {
+ const repo = {
+ importSource: {
+ id: 'remote-1',
+ fullName: 'fullName',
+ providerLink: 'providerLink',
+ },
+ importedProject: {
+ id: 1,
+ fullPath: 'fullPath',
+ importSource: 'importSource',
+ importStatus: STATUSES.FINISHED,
+ },
+ };
+
+ beforeEach(() => {
+ mountComponent({ repo });
+ });
+
+ it('renders project information', () => {
+ const providerLink = wrapper.find('[data-testid=providerLink]');
+
+ expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink);
+ expect(providerLink.text()).toMatch(repo.importSource.fullName);
+ });
+
+ it('renders proper import status', () => {
+ expect(wrapper.find(ImportStatus).props().status).toBe(repo.importedProject.importStatus);
+ });
+
+ it('does not renders a namespace select', () => {
+ expect(wrapper.find(Select2Select).exists()).toBe(false);
+ });
+
+ it('does not render import button', () => {
+ expect(findImportButton().exists()).toBe(false);
+ });
});
- it('imports repo when clicking import button', async () => {
- findImportButton().trigger('click');
+ describe('when rendering incompatible project', () => {
+ const repo = {
+ importSource: {
+ id: 'remote-1',
+ fullName: 'fullName',
+ providerLink: 'providerLink',
+ incompatible: true,
+ },
+ };
- await nextTick();
+ beforeEach(() => {
+ mountComponent({ repo });
+ });
+
+ it('renders project information', () => {
+ const providerLink = wrapper.find('[data-testid=providerLink]');
- const { calls } = fetchImport.mock;
+ expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink);
+ expect(providerLink.text()).toMatch(repo.importSource.fullName);
+ });
- expect(calls).toHaveLength(1);
- expect(calls[0][1]).toBe(repo.importSource.id);
+ it('renders badge with error', () => {
+ expect(wrapper.find(GlBadge).text()).toBe('Incompatible project');
+ });
});
});
diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js
index 45a59b3f6d6..6951f2bf04d 100644
--- a/spec/frontend/import_projects/store/actions_spec.js
+++ b/spec/frontend/import_projects/store/actions_spec.js
@@ -83,7 +83,7 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
- it('dispatches stopJobsPolling actions and commits REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => {
+ it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => {
mock.onGet(MOCK_ENDPOINT).reply(200, payload);
return testAction(
@@ -91,54 +91,65 @@ describe('import_projects store actions', () => {
null,
localState,
[
+ { type: SET_PAGE, payload: 1 },
{ type: REQUEST_REPOS },
{
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
],
- [{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }],
+ [],
);
});
- it('dispatches stopJobsPolling action and commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => {
+ it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_ERROR and SET_PAGE again mutations on an unsuccessful request', () => {
mock.onGet(MOCK_ENDPOINT).reply(500);
return testAction(
fetchRepos,
null,
localState,
- [{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }],
- [{ type: 'stopJobsPolling' }],
+ [
+ { type: SET_PAGE, payload: 1 },
+ { type: REQUEST_REPOS },
+ { type: SET_PAGE, payload: 0 },
+ { type: RECEIVE_REPOS_ERROR },
+ ],
+ [],
);
});
- describe('when pagination is enabled', () => {
- it('includes page in url query params', async () => {
- const { fetchRepos: fetchReposWithPagination } = actionsFactory({
- endpoints,
- hasPagination: true,
- });
+ it('includes page in url query params', async () => {
+ let requestedUrl;
+ mock.onGet().reply(config => {
+ requestedUrl = config.url;
+ return [200, payload];
+ });
- let requestedUrl;
- mock.onGet().reply(config => {
- requestedUrl = config.url;
- return [200, payload];
- });
+ const localStateWithPage = { ...localState, pageInfo: { page: 2 } };
- await testAction(
- fetchReposWithPagination,
- null,
- localState,
- expect.any(Array),
- expect.any(Array),
- );
+ await testAction(fetchRepos, null, localStateWithPage, expect.any(Array), expect.any(Array));
- expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localState.pageInfo.page}`);
- });
+ expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`);
});
- describe('when filtered', () => {
+ it('correctly updates current page on an unsuccessful request', () => {
+ mock.onGet(MOCK_ENDPOINT).reply(500);
+ const CURRENT_PAGE = 5;
+
+ return testAction(
+ fetchRepos,
+ null,
+ { ...localState, pageInfo: { page: CURRENT_PAGE } },
+ expect.arrayContaining([
+ { type: SET_PAGE, payload: CURRENT_PAGE + 1 },
+ { type: SET_PAGE, payload: CURRENT_PAGE },
+ ]),
+ [],
+ );
+ });
+
+ describe('when /home/xanf/projects/gdk/gitlab/spec/frontend/import_projects/store/actions_spec.jsfiltered', () => {
it('fetches repos with filter applied', () => {
mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload);
@@ -147,13 +158,14 @@ describe('import_projects store actions', () => {
null,
{ ...localState, filter: 'filter' },
[
+ { type: SET_PAGE, payload: 1 },
{ type: REQUEST_REPOS },
{
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
],
- [{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }],
+ [],
);
});
});
diff --git a/spec/frontend/import_projects/store/getters_spec.js b/spec/frontend/import_projects/store/getters_spec.js
index 5c1ea25a684..1ce42e534ea 100644
--- a/spec/frontend/import_projects/store/getters_spec.js
+++ b/spec/frontend/import_projects/store/getters_spec.js
@@ -3,6 +3,7 @@ import {
isImportingAnyRepo,
hasIncompatibleRepos,
hasImportableRepos,
+ importAllCount,
getImportTarget,
} from '~/import_projects/store/getters';
import { STATUSES } from '~/import_projects/constants';
@@ -10,13 +11,12 @@ import state from '~/import_projects/store/state';
const IMPORTED_REPO = {
importSource: {},
- importedProject: { fullPath: 'some/path' },
+ importedProject: { fullPath: 'some/path', importStatus: STATUSES.FINISHED },
};
const IMPORTABLE_REPO = {
importSource: { id: 'some-id', sanitizedName: 'sanitized' },
importedProject: null,
- importStatus: STATUSES.NONE,
};
const INCOMPATIBLE_REPO = {
@@ -56,14 +56,20 @@ describe('import_projects store getters', () => {
${STATUSES.STARTED} | ${true}
${STATUSES.FINISHED} | ${false}
`(
- 'isImportingAnyRepo returns $value when repo with $importStatus status is available',
+ 'isImportingAnyRepo returns $value when project with $importStatus status is available',
({ importStatus, value }) => {
- localState.repositories = [{ importStatus }];
+ localState.repositories = [{ importedProject: { importStatus } }];
expect(isImportingAnyRepo(localState)).toBe(value);
},
);
+ it('isImportingAnyRepo returns false when project with no defined importStatus status is available', () => {
+ localState.repositories = [{ importSource: {} }];
+
+ expect(isImportingAnyRepo(localState)).toBe(false);
+ });
+
describe('hasIncompatibleRepos', () => {
it('returns true if there are any incompatible projects', () => {
localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO];
@@ -92,6 +98,19 @@ describe('import_projects store getters', () => {
});
});
+ describe('importAllCount', () => {
+ it('returns count of available importable projects ', () => {
+ localState.repositories = [
+ IMPORTABLE_REPO,
+ IMPORTABLE_REPO,
+ IMPORTED_REPO,
+ INCOMPATIBLE_REPO,
+ ];
+
+ expect(importAllCount(localState)).toBe(2);
+ });
+ });
+
describe('getImportTarget', () => {
it('returns default value if no custom target available', () => {
localState.defaultTargetNamespace = 'default';
diff --git a/spec/frontend/import_projects/store/mutations_spec.js b/spec/frontend/import_projects/store/mutations_spec.js
index 3672ec9f2c0..5d78a7fa9e7 100644
--- a/spec/frontend/import_projects/store/mutations_spec.js
+++ b/spec/frontend/import_projects/store/mutations_spec.js
@@ -1,9 +1,11 @@
import * as types from '~/import_projects/store/mutation_types';
import mutations from '~/import_projects/store/mutations';
+import getInitialState from '~/import_projects/store/state';
import { STATUSES } from '~/import_projects/constants';
describe('import_projects store mutations', () => {
let state;
+
const SOURCE_PROJECT = {
id: 1,
full_name: 'full/name',
@@ -19,13 +21,23 @@ describe('import_projects store mutations', () => {
};
describe(`${types.SET_FILTER}`, () => {
- it('overwrites current filter value', () => {
- state = { filter: 'some-value' };
- const NEW_VALUE = 'new-value';
+ const NEW_VALUE = 'new-value';
+ beforeEach(() => {
+ state = {
+ filter: 'some-value',
+ repositories: ['some', ' repositories'],
+ pageInfo: { page: 1 },
+ };
mutations[types.SET_FILTER](state, NEW_VALUE);
+ });
- expect(state.filter).toBe(NEW_VALUE);
+ it('removes current repositories list', () => {
+ expect(state.repositories.length).toBe(0);
+ });
+
+ it('resets current page to 0', () => {
+ expect(state.pageInfo.page).toBe(0);
});
});
@@ -40,93 +52,104 @@ describe('import_projects store mutations', () => {
});
describe(`${types.RECEIVE_REPOS_SUCCESS}`, () => {
- describe('for imported projects', () => {
- const response = {
- importedProjects: [IMPORTED_PROJECT],
- providerRepos: [],
- };
+ describe('with legacy response format', () => {
+ describe('for imported projects', () => {
+ const response = {
+ importedProjects: [IMPORTED_PROJECT],
+ providerRepos: [],
+ };
- it('picks import status from response', () => {
- state = {};
+ it('recreates importSource from response', () => {
+ state = getInitialState();
- mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
+ mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
- expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus);
- });
+ expect(state.repositories[0].importSource).toStrictEqual(
+ expect.objectContaining({
+ fullName: IMPORTED_PROJECT.importSource,
+ sanitizedName: IMPORTED_PROJECT.name,
+ providerLink: IMPORTED_PROJECT.providerLink,
+ }),
+ );
+ });
- it('recreates importSource from response', () => {
- state = {};
+ it('passes project to importProject', () => {
+ state = getInitialState();
- mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
+ mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
- expect(state.repositories[0].importSource).toStrictEqual(
- expect.objectContaining({
- fullName: IMPORTED_PROJECT.importSource,
- sanitizedName: IMPORTED_PROJECT.name,
- providerLink: IMPORTED_PROJECT.providerLink,
- }),
- );
+ expect(IMPORTED_PROJECT).toStrictEqual(
+ expect.objectContaining(state.repositories[0].importedProject),
+ );
+ });
});
- it('passes project to importProject', () => {
- state = {};
+ describe('for importable projects', () => {
+ beforeEach(() => {
+ state = getInitialState();
- mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
+ const response = {
+ importedProjects: [],
+ providerRepos: [SOURCE_PROJECT],
+ };
+ mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
+ });
- expect(IMPORTED_PROJECT).toStrictEqual(
- expect.objectContaining(state.repositories[0].importedProject),
- );
+ it('sets importSource to project', () => {
+ expect(state.repositories[0].importSource).toBe(SOURCE_PROJECT);
+ });
});
- });
- describe('for importable projects', () => {
- beforeEach(() => {
- state = {};
+ describe('for incompatible projects', () => {
const response = {
importedProjects: [],
- providerRepos: [SOURCE_PROJECT],
+ providerRepos: [],
+ incompatibleRepos: [SOURCE_PROJECT],
};
- mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
- });
- it('sets import status to none', () => {
- expect(state.repositories[0].importStatus).toBe(STATUSES.NONE);
- });
+ beforeEach(() => {
+ state = getInitialState();
+ mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
+ });
+
+ it('sets incompatible flag', () => {
+ expect(state.repositories[0].importSource.incompatible).toBe(true);
+ });
- it('sets importSource to project', () => {
- expect(state.repositories[0].importSource).toBe(SOURCE_PROJECT);
+ it('sets importSource to project', () => {
+ expect(state.repositories[0].importSource).toStrictEqual(
+ expect.objectContaining(SOURCE_PROJECT),
+ );
+ });
});
- });
- describe('for incompatible projects', () => {
- const response = {
- importedProjects: [],
- providerRepos: [],
- incompatibleRepos: [SOURCE_PROJECT],
- };
+ it('sets repos loading flag to false', () => {
+ const response = {
+ importedProjects: [],
+ providerRepos: [],
+ };
+
+ state = getInitialState();
- beforeEach(() => {
- state = {};
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
- });
- it('sets incompatible flag', () => {
- expect(state.repositories[0].importSource.incompatible).toBe(true);
+ expect(state.isLoadingRepos).toBe(false);
});
+ });
- it('sets importSource to project', () => {
- expect(state.repositories[0].importSource).toStrictEqual(
- expect.objectContaining(SOURCE_PROJECT),
- );
- });
+ it('passes response as it is', () => {
+ const response = [];
+ state = getInitialState();
+
+ mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
+
+ expect(state.repositories).toStrictEqual(response);
});
it('sets repos loading flag to false', () => {
- const response = {
- importedProjects: [],
- providerRepos: [],
- };
- state = {};
+ const response = [];
+
+ state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
@@ -136,7 +159,7 @@ describe('import_projects store mutations', () => {
describe(`${types.RECEIVE_REPOS_ERROR}`, () => {
it('sets repos loading flag to false', () => {
- state = {};
+ state = getInitialState();
mutations[types.RECEIVE_REPOS_ERROR](state);
@@ -154,7 +177,7 @@ describe('import_projects store mutations', () => {
});
it(`sets status to ${STATUSES.SCHEDULING}`, () => {
- expect(state.repositories[0].importStatus).toBe(STATUSES.SCHEDULING);
+ expect(state.repositories[0].importedProject.importStatus).toBe(STATUSES.SCHEDULING);
});
});
@@ -170,7 +193,9 @@ describe('import_projects store mutations', () => {
});
it('sets import status', () => {
- expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus);
+ expect(state.repositories[0].importedProject.importStatus).toBe(
+ IMPORTED_PROJECT.importStatus,
+ );
});
it('sets imported project', () => {
@@ -188,8 +213,8 @@ describe('import_projects store mutations', () => {
mutations[types.RECEIVE_IMPORT_ERROR](state, REPO_ID);
});
- it(`resets import status to ${STATUSES.NONE}`, () => {
- expect(state.repositories[0].importStatus).toBe(STATUSES.NONE);
+ it(`removes importedProject entry`, () => {
+ expect(state.repositories[0].importedProject).toBeNull();
});
});
@@ -203,7 +228,9 @@ describe('import_projects store mutations', () => {
mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects);
- expect(state.repositories[0].importStatus).toBe(updatedProjects[0].importStatus);
+ expect(state.repositories[0].importedProject.importStatus).toBe(
+ updatedProjects[0].importStatus,
+ );
});
});
@@ -280,17 +307,6 @@ describe('import_projects store mutations', () => {
});
});
- describe(`${types.SET_PAGE_INFO}`, () => {
- it('sets passed page info', () => {
- state = {};
- const pageInfo = { page: 1, total: 10 };
-
- mutations[types.SET_PAGE_INFO](state, pageInfo);
-
- expect(state.pageInfo).toBe(pageInfo);
- });
- });
-
describe(`${types.SET_PAGE}`, () => {
it('sets page number', () => {
const NEW_PAGE = 4;
diff --git a/spec/frontend/import_projects/utils_spec.js b/spec/frontend/import_projects/utils_spec.js
index 826b06d5a70..4e1e16a3184 100644
--- a/spec/frontend/import_projects/utils_spec.js
+++ b/spec/frontend/import_projects/utils_spec.js
@@ -1,7 +1,16 @@
-import { isProjectImportable } from '~/import_projects/utils';
+import { isProjectImportable, isIncompatible, getImportStatus } from '~/import_projects/utils';
import { STATUSES } from '~/import_projects/constants';
describe('import_projects utils', () => {
+ const COMPATIBLE_PROJECT = {
+ importSource: { incompatible: false },
+ };
+
+ const INCOMPATIBLE_PROJECT = {
+ importSource: { incompatible: true },
+ importedProject: null,
+ };
+
describe('isProjectImportable', () => {
it.each`
status | result
@@ -14,19 +23,43 @@ describe('import_projects utils', () => {
`('returns $result when project is compatible and status is $status', ({ status, result }) => {
expect(
isProjectImportable({
- importStatus: status,
- importSource: { incompatible: false },
+ ...COMPATIBLE_PROJECT,
+ importedProject: { importStatus: status },
}),
).toBe(result);
});
+ it('returns true if import status is not defined', () => {
+ expect(isProjectImportable({ importSource: {} })).toBe(true);
+ });
+
it('returns false if project is not compatible', () => {
+ expect(isProjectImportable(INCOMPATIBLE_PROJECT)).toBe(false);
+ });
+ });
+
+ describe('isIncompatible', () => {
+ it('returns true for incompatible project', () => {
+ expect(isIncompatible(INCOMPATIBLE_PROJECT)).toBe(true);
+ });
+
+ it('returns false for compatible project', () => {
+ expect(isIncompatible(COMPATIBLE_PROJECT)).toBe(false);
+ });
+ });
+
+ describe('getImportStatus', () => {
+ it('returns actual status when project status is provided', () => {
expect(
- isProjectImportable({
- importStatus: STATUSES.NONE,
- importSource: { incompatible: true },
+ getImportStatus({
+ ...COMPATIBLE_PROJECT,
+ importedProject: { importStatus: STATUSES.FINISHED },
}),
- ).toBe(false);
+ ).toBe(STATUSES.FINISHED);
+ });
+
+ it('returns NONE as status if import status is not provided', () => {
+ expect(getImportStatus(COMPATIBLE_PROJECT)).toBe(STATUSES.NONE);
});
});
});
diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js
index 33ddd06d6d9..307806e0a8a 100644
--- a/spec/frontend/incidents/components/incidents_list_spec.js
+++ b/spec/frontend/incidents/components/incidents_list_spec.js
@@ -13,6 +13,7 @@ import {
} from '@gitlab/ui';
import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility';
import IncidentsList from '~/incidents/components/incidents_list.vue';
+import SeverityToken from '~/sidebar/components/severity/severity.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { I18N, INCIDENT_STATUS_TABS } from '~/incidents/constants';
import mockIncidents from '../mocks/incidents.json';
@@ -30,9 +31,9 @@ describe('Incidents List', () => {
const incidentTemplateName = 'incident';
const incidentType = 'incident';
const incidentsCount = {
- opened: 14,
- closed: 1,
- all: 16,
+ opened: 24,
+ closed: 10,
+ all: 26,
};
const findTable = () => wrapper.find(GlTable);
@@ -51,6 +52,7 @@ describe('Incidents List', () => {
const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
const findStatusTabs = () => wrapper.find(GlTabs);
const findEmptyState = () => wrapper.find(GlEmptyState);
+ const findSeverity = () => wrapper.findAll(SeverityToken);
function mountComponent({ data = { incidents: [], incidentsCount: {} }, loading = false }) {
wrapper = mount(IncidentsList, {
@@ -78,6 +80,7 @@ describe('Incidents List', () => {
stubs: {
GlButton: true,
GlAvatar: true,
+ GlEmptyState: true,
},
});
}
@@ -96,12 +99,30 @@ describe('Incidents List', () => {
expect(findLoader().exists()).toBe(true);
});
- it('shows empty state', () => {
- mountComponent({
- data: { incidents: { list: [] }, incidentsCount: {} },
- loading: false,
- });
- expect(findEmptyState().exists()).toBe(true);
+ describe('empty state', () => {
+ const {
+ emptyState: { title, emptyClosedTabTitle, description },
+ } = I18N;
+
+ it.each`
+ statusFilter | all | closed | expectedTitle | expectedDescription
+ ${'all'} | ${2} | ${1} | ${title} | ${description}
+ ${'open'} | ${2} | ${0} | ${title} | ${description}
+ ${'closed'} | ${0} | ${0} | ${title} | ${description}
+ ${'closed'} | ${2} | ${0} | ${emptyClosedTabTitle} | ${undefined}
+ `(
+ `when active tab is $statusFilter and there are $all incidents in total and $closed closed incidents, the empty state
+ has title: $expectedTitle and description: $expectedDescription`,
+ ({ statusFilter, all, closed, expectedTitle, expectedDescription }) => {
+ mountComponent({
+ data: { incidents: { list: [] }, incidentsCount: { all, closed }, statusFilter },
+ loading: false,
+ });
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findEmptyState().attributes('title')).toBe(expectedTitle);
+ expect(findEmptyState().attributes('description')).toBe(expectedDescription);
+ },
+ );
});
it('shows error state', () => {
@@ -163,6 +184,10 @@ describe('Incidents List', () => {
);
});
});
+
+ it('renders severity per row', () => {
+ expect(findSeverity().length).toBe(mockIncidents.length);
+ });
});
describe('Create Incident', () => {
@@ -188,6 +213,14 @@ describe('Incidents List', () => {
expect(findCreateIncidentBtn().attributes('loading')).toBe('true');
});
});
+
+ it("doesn't show the button when list is empty", () => {
+ mountComponent({
+ data: { incidents: { list: [] }, incidentsCount: {} },
+ loading: false,
+ });
+ expect(findCreateIncidentBtn().exists()).toBe(false);
+ });
});
describe('Pagination', () => {
@@ -313,7 +346,7 @@ describe('Incidents List', () => {
describe('Status Filter Tabs', () => {
beforeEach(() => {
mountComponent({
- data: { incidents: mockIncidents, incidentsCount },
+ data: { incidents: { list: mockIncidents }, incidentsCount },
loading: false,
stubs: {
GlTab: true,
@@ -345,7 +378,7 @@ describe('Incidents List', () => {
describe('sorting the incident list by column', () => {
beforeEach(() => {
mountComponent({
- data: { incidents: mockIncidents, incidentsCount },
+ data: { incidents: { list: mockIncidents }, incidentsCount },
loading: false,
});
});
diff --git a/spec/frontend/incidents/mocks/incidents.json b/spec/frontend/incidents/mocks/incidents.json
index 4eab709e53f..42b3d6d3eb6 100644
--- a/spec/frontend/incidents/mocks/incidents.json
+++ b/spec/frontend/incidents/mocks/incidents.json
@@ -4,7 +4,8 @@
"title": "New: Incident",
"createdAt": "2020-06-03T15:46:08Z",
"assignees": {},
- "state": "opened"
+ "state": "opened",
+ "severity": "CRITICAL"
},
{
"iid": "14",
@@ -20,20 +21,23 @@
}
]
},
- "state": "opened"
+ "state": "opened",
+ "severity": "HIGH"
},
{
"iid": "13",
"title": "Create issue3",
"createdAt": "2020-05-19T08:53:55Z",
"assignees": {},
- "state": "closed"
+ "state": "closed",
+ "severity": "LOW"
},
{
"iid": "12",
"title": "Create issue2",
"createdAt": "2020-05-18T17:13:35Z",
"assignees": {},
- "state": "closed"
+ "state": "closed",
+ "severity": "MEDIUM"
}
]
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
index f3f610e4bb7..cab2165b5db 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
@@ -45,7 +45,7 @@ exports[`Alert integration settings form default state should match the default
</gl-link-stub>
</label>
- <gl-new-dropdown-stub
+ <gl-dropdown-stub
block="true"
category="tertiary"
data-qa-selector="incident_templates_dropdown"
@@ -55,7 +55,7 @@ exports[`Alert integration settings form default state should match the default
text="selecte_tmpl"
variant="default"
>
- <gl-new-dropdown-item-stub
+ <gl-dropdown-item-stub
avatarurl=""
data-qa-selector="incident_templates_item"
iconcolor=""
@@ -67,8 +67,8 @@ exports[`Alert integration settings form default state should match the default
No template selected
- </gl-new-dropdown-item-stub>
- </gl-new-dropdown-stub>
+ </gl-dropdown-item-stub>
+ </gl-dropdown-stub>
</gl-form-group-stub>
<gl-form-group-stub
@@ -81,6 +81,18 @@ exports[`Alert integration settings form default state should match the default
</gl-form-checkbox-stub>
</gl-form-group-stub>
+ <gl-form-group-stub
+ class="gl-pl-0 gl-mb-5"
+ >
+ <gl-form-checkbox-stub
+ checked="true"
+ >
+ <span>
+ Automatically close incident issues when the associated Prometheus alert resolves.
+ </span>
+ </gl-form-checkbox-stub>
+ </gl-form-group-stub>
+
<div
class="gl-display-flex gl-justify-content-end"
>
diff --git a/spec/frontend/incidents_settings/components/alerts_form_spec.js b/spec/frontend/incidents_settings/components/alerts_form_spec.js
index 04832f31e58..2516e8afdfa 100644
--- a/spec/frontend/incidents_settings/components/alerts_form_spec.js
+++ b/spec/frontend/incidents_settings/components/alerts_form_spec.js
@@ -16,6 +16,7 @@ describe('Alert integration settings form', () => {
createIssue: true,
sendEmail: false,
templates: [],
+ autoCloseIncident: true,
},
},
});
@@ -42,6 +43,7 @@ describe('Alert integration settings form', () => {
create_issue: wrapper.vm.createIssueEnabled,
issue_template_key: wrapper.vm.issueTemplate,
send_email: wrapper.vm.sendEmailEnabled,
+ auto_close_incident: wrapper.vm.autoCloseIncident,
}),
);
});
diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
new file mode 100644
index 00000000000..38bcb1e0aab
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
@@ -0,0 +1,76 @@
+import { mount } from '@vue/test-utils';
+import { GlFormCheckbox } from '@gitlab/ui';
+import { createStore } from '~/integrations/edit/store';
+
+import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
+
+describe('ActiveCheckbox', () => {
+ let wrapper;
+
+ const createComponent = (customStateProps = {}, isInheriting = false) => {
+ wrapper = mount(ActiveCheckbox, {
+ store: createStore({
+ customState: { ...customStateProps },
+ }),
+ computed: {
+ isInheriting: () => isInheriting,
+ },
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findGlFormCheckbox = () => wrapper.find(GlFormCheckbox);
+ const findInputInCheckbox = () => findGlFormCheckbox().find('input');
+
+ describe('template', () => {
+ describe('is inheriting adminSettings', () => {
+ it('renders GlFormCheckbox as disabled', () => {
+ createComponent({}, true);
+
+ expect(findGlFormCheckbox().exists()).toBe(true);
+ expect(findInputInCheckbox().attributes('disabled')).toBe('disabled');
+ });
+ });
+
+ describe('initialActivated is false', () => {
+ it('renders GlFormCheckbox as unchecked', () => {
+ createComponent({
+ initialActivated: false,
+ });
+
+ expect(findGlFormCheckbox().exists()).toBe(true);
+ expect(findGlFormCheckbox().vm.$attrs.checked).toBe(false);
+ expect(findInputInCheckbox().attributes('disabled')).toBeUndefined();
+ });
+ });
+
+ describe('initialActivated is true', () => {
+ beforeEach(() => {
+ createComponent({
+ initialActivated: true,
+ });
+ });
+
+ it('renders GlFormCheckbox as checked', () => {
+ expect(findGlFormCheckbox().exists()).toBe(true);
+ expect(findGlFormCheckbox().vm.$attrs.checked).toBe(true);
+ });
+
+ describe('on checkbox click', () => {
+ it('switches the form value', async () => {
+ findInputInCheckbox().trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findGlFormCheckbox().vm.$attrs.checked).toBe(false);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/active_toggle_spec.js b/spec/frontend/integrations/edit/components/active_toggle_spec.js
deleted file mode 100644
index 228d8f5fc30..00000000000
--- a/spec/frontend/integrations/edit/components/active_toggle_spec.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { GlToggle } from '@gitlab/ui';
-
-import ActiveToggle from '~/integrations/edit/components/active_toggle.vue';
-
-const GL_TOGGLE_ACTIVE_CLASS = 'is-checked';
-const GL_TOGGLE_DISABLED_CLASS = 'is-disabled';
-
-describe('ActiveToggle', () => {
- let wrapper;
-
- const defaultProps = {
- initialActivated: true,
- };
-
- const createComponent = (props = {}, isInheriting = false) => {
- wrapper = mount(ActiveToggle, {
- propsData: { ...defaultProps, ...props },
- computed: {
- isInheriting: () => isInheriting,
- },
- });
- };
-
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
- const findGlToggle = () => wrapper.find(GlToggle);
- const findButtonInToggle = () => findGlToggle().find('button');
- const findInputInToggle = () => findGlToggle().find('input');
-
- describe('template', () => {
- describe('is inheriting adminSettings', () => {
- it('renders GlToggle as disabled', () => {
- createComponent({}, true);
-
- expect(findGlToggle().exists()).toBe(true);
- expect(findButtonInToggle().classes()).toContain(GL_TOGGLE_DISABLED_CLASS);
- });
- });
-
- describe('initialActivated is false', () => {
- it('renders GlToggle as inactive', () => {
- createComponent({
- initialActivated: false,
- });
-
- expect(findGlToggle().exists()).toBe(true);
- expect(findButtonInToggle().classes()).not.toContain(GL_TOGGLE_ACTIVE_CLASS);
- expect(findInputInToggle().attributes('value')).toBe('false');
- });
- });
-
- describe('initialActivated is true', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders GlToggle as active', () => {
- expect(findGlToggle().exists()).toBe(true);
- expect(findButtonInToggle().classes()).toContain(GL_TOGGLE_ACTIVE_CLASS);
- expect(findInputInToggle().attributes('value')).toBe('true');
- });
-
- describe('on toggle click', () => {
- it('switches the form value', () => {
- findButtonInToggle().trigger('click');
-
- wrapper.vm.$nextTick(() => {
- expect(findButtonInToggle().classes()).not.toContain(GL_TOGGLE_ACTIVE_CLASS);
- expect(findInputInToggle().attributes('value')).toBe('false');
- });
- });
- });
- });
- });
-});
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index f8e2eb5e7f4..eeb5d21d62c 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -3,7 +3,7 @@ import { mockIntegrationProps } from 'jest/integrations/edit/mock_data';
import { createStore } from '~/integrations/edit/store';
import IntegrationForm from '~/integrations/edit/components/integration_form.vue';
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
-import ActiveToggle from '~/integrations/edit/components/active_toggle.vue';
+import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
@@ -21,7 +21,7 @@ describe('IntegrationForm', () => {
}),
stubs: {
OverrideDropdown,
- ActiveToggle,
+ ActiveCheckbox,
JiraTriggerFields,
TriggerFields,
},
@@ -39,27 +39,27 @@ describe('IntegrationForm', () => {
});
const findOverrideDropdown = () => wrapper.find(OverrideDropdown);
- const findActiveToggle = () => wrapper.find(ActiveToggle);
+ const findActiveCheckbox = () => wrapper.find(ActiveCheckbox);
const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields);
const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields);
const findTriggerFields = () => wrapper.find(TriggerFields);
describe('template', () => {
describe('showActive is true', () => {
- it('renders ActiveToggle', () => {
+ it('renders ActiveCheckbox', () => {
createComponent();
- expect(findActiveToggle().exists()).toBe(true);
+ expect(findActiveCheckbox().exists()).toBe(true);
});
});
describe('showActive is false', () => {
- it('does not render ActiveToggle', () => {
+ it('does not render ActiveCheckbox', () => {
createComponent({
showActive: false,
});
- expect(findActiveToggle().exists()).toBe(false);
+ expect(findActiveCheckbox().exists()).toBe(false);
});
});
@@ -137,13 +137,13 @@ describe('IntegrationForm', () => {
});
});
- describe('adminState state is null', () => {
+ describe('defaultState state is null', () => {
it('does not render OverrideDropdown', () => {
createComponent(
{},
{},
{
- adminState: null,
+ defaultState: null,
},
);
@@ -151,13 +151,13 @@ describe('IntegrationForm', () => {
});
});
- describe('adminState state is an object', () => {
+ describe('defaultState state is an object', () => {
it('renders OverrideDropdown', () => {
createComponent(
{},
{},
{
- adminState: {
+ defaultState: {
...mockIntegrationProps,
},
},
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 f58825f6297..a727bb9c734 100644
--- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
@@ -57,7 +57,7 @@ describe('JiraIssuesFields', () => {
// As per https://vuejs.org/v2/guide/forms.html#Checkbox-1,
// browsers don't include unchecked boxes in form submissions.
it('includes issues_enabled as false even if unchecked', () => {
- expect(wrapper.contains('input[name="service[issues_enabled]"]')).toBe(true);
+ expect(wrapper.find('input[name="service[issues_enabled]"]').exists()).toBe(true);
});
it('disables project_key input', () => {
@@ -90,7 +90,23 @@ describe('JiraIssuesFields', () => {
it('contains link to editProjectPath', () => {
createComponent();
- expect(wrapper.contains(`a[href="${defaultProps.editProjectPath}"]`)).toBe(true);
+ expect(wrapper.find(`a[href="${defaultProps.editProjectPath}"]`).exists()).toBe(true);
+ });
+
+ describe('GitLab issues warning', () => {
+ const expectedText = 'Consider disabling GitLab issues';
+
+ it('contains warning when GitLab issues is enabled', () => {
+ createComponent();
+
+ expect(wrapper.text()).toContain(expectedText);
+ });
+
+ it('does not contain warning when GitLab issues is disabled', () => {
+ createComponent({ gitlabIssuesEnabled: false });
+
+ expect(wrapper.text()).not.toContain(expectedText);
+ });
});
});
});
diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js
new file mode 100644
index 00000000000..f312c456d5f
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js
@@ -0,0 +1,106 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlLink } from '@gitlab/ui';
+import { createStore } from '~/integrations/edit/store';
+
+import { integrationLevels, overrideDropdownDescriptions } from '~/integrations/edit/constants';
+import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
+
+describe('OverrideDropdown', () => {
+ let wrapper;
+
+ const defaultProps = {
+ inheritFromId: 1,
+ override: true,
+ };
+
+ const defaultDefaultStateProps = {
+ integrationLevel: 'group',
+ };
+
+ const createComponent = (props = {}, defaultStateProps = {}) => {
+ wrapper = shallowMount(OverrideDropdown, {
+ propsData: { ...defaultProps, ...props },
+ store: createStore({
+ defaultState: { ...defaultDefaultStateProps, ...defaultStateProps },
+ }),
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findGlLink = () => wrapper.find(GlLink);
+ const findGlDropdown = () => wrapper.find(GlDropdown);
+
+ describe('template', () => {
+ describe('override prop is true', () => {
+ it('renders GlToggle as disabled', () => {
+ createComponent();
+
+ expect(findGlDropdown().props('text')).toBe('Use custom settings');
+ });
+ });
+
+ describe('override prop is false', () => {
+ it('renders GlToggle as disabled', () => {
+ createComponent({ override: false });
+
+ expect(findGlDropdown().props('text')).toBe('Use default settings');
+ });
+ });
+
+ describe('integrationLevel is "project"', () => {
+ it('renders copy mentioning instance (as default fallback)', () => {
+ createComponent(
+ {},
+ {
+ integrationLevel: 'project',
+ },
+ );
+
+ expect(wrapper.text()).toContain(overrideDropdownDescriptions[integrationLevels.INSTANCE]);
+ });
+ });
+
+ describe('integrationLevel is "group"', () => {
+ it('renders copy mentioning group', () => {
+ createComponent(
+ {},
+ {
+ integrationLevel: 'group',
+ },
+ );
+
+ expect(wrapper.text()).toContain(overrideDropdownDescriptions[integrationLevels.GROUP]);
+ });
+ });
+
+ describe('integrationLevel is "instance"', () => {
+ it('renders copy mentioning instance', () => {
+ createComponent(
+ {},
+ {
+ integrationLevel: 'instance',
+ },
+ );
+
+ expect(wrapper.text()).toContain(overrideDropdownDescriptions[integrationLevels.INSTANCE]);
+ });
+ });
+
+ describe('learnMorePath is present', () => {
+ it('renders GlLink with correct link', () => {
+ createComponent({
+ learnMorePath: '/docs',
+ });
+
+ expect(findGlLink().text()).toBe('Learn more');
+ expect(findGlLink().attributes('href')).toBe('/docs');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js
index da2758ec15c..821972b7698 100644
--- a/spec/frontend/integrations/edit/mock_data.js
+++ b/spec/frontend/integrations/edit/mock_data.js
@@ -1,9 +1,6 @@
-// eslint-disable-next-line import/prefer-default-export
export const mockIntegrationProps = {
id: 25,
- activeToggleProps: {
- initialActivated: true,
- },
+ initialActivated: true,
showActive: true,
triggerFieldsProps: {
initialTriggerCommit: false,
diff --git a/spec/frontend/integrations/edit/store/getters_spec.js b/spec/frontend/integrations/edit/store/getters_spec.js
index 700d36edaad..3353e0c84cc 100644
--- a/spec/frontend/integrations/edit/store/getters_spec.js
+++ b/spec/frontend/integrations/edit/store/getters_spec.js
@@ -5,22 +5,22 @@ import { mockIntegrationProps } from '../mock_data';
describe('Integration form store getters', () => {
let state;
const customState = { ...mockIntegrationProps, type: 'CustomState' };
- const adminState = { ...mockIntegrationProps, type: 'AdminState' };
+ const defaultState = { ...mockIntegrationProps, type: 'DefaultState' };
beforeEach(() => {
state = createState({ customState });
});
describe('isInheriting', () => {
- describe('when adminState is null', () => {
+ describe('when defaultState is null', () => {
it('returns false', () => {
expect(isInheriting(state)).toBe(false);
});
});
- describe('when adminState is an object', () => {
+ describe('when defaultState is an object', () => {
beforeEach(() => {
- state.adminState = adminState;
+ state.defaultState = defaultState;
});
describe('when override is false', () => {
@@ -47,11 +47,11 @@ describe('Integration form store getters', () => {
describe('propsSource', () => {
beforeEach(() => {
- state.adminState = adminState;
+ state.defaultState = defaultState;
});
- it('equals adminState if inheriting', () => {
- expect(propsSource(state, { isInheriting: true })).toEqual(adminState);
+ it('equals defaultState if inheriting', () => {
+ expect(propsSource(state, { isInheriting: true })).toEqual(defaultState);
});
it('equals customState if not inheriting', () => {
diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js
index a8b431aa310..fc193850a94 100644
--- a/spec/frontend/integrations/edit/store/state_spec.js
+++ b/spec/frontend/integrations/edit/store/state_spec.js
@@ -3,8 +3,10 @@ import createState from '~/integrations/edit/store/state';
describe('Integration form state factory', () => {
it('states default to null', () => {
expect(createState()).toEqual({
- adminState: null,
+ defaultState: null,
customState: {},
+ isSaving: false,
+ isTesting: false,
override: false,
});
});
@@ -17,9 +19,9 @@ describe('Integration form state factory', () => {
[null, { inheritFromId: null }, false],
[null, { inheritFromId: 25 }, false],
])(
- 'for adminState: %p, customState: %p: override = `%p`',
- (adminState, customState, expected) => {
- expect(createState({ adminState, customState }).override).toEqual(expected);
+ 'for defaultState: %p, customState: %p: override = `%p`',
+ (defaultState, customState, expected) => {
+ expect(createState({ defaultState, customState }).override).toEqual(expected);
},
);
});
diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js
index c117a37ff2f..bba851ad796 100644
--- a/spec/frontend/integrations/integration_settings_form_spec.js
+++ b/spec/frontend/integrations/integration_settings_form_spec.js
@@ -1,7 +1,9 @@
-import $ from 'jquery';
import MockAdaptor from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
+import toast from '~/vue_shared/plugins/global_toast';
+
+jest.mock('~/vue_shared/plugins/global_toast');
describe('IntegrationSettingsForm', () => {
const FIXTURE = 'services/edit_service.html';
@@ -11,7 +13,7 @@ describe('IntegrationSettingsForm', () => {
loadFixtures(FIXTURE);
});
- describe('contructor', () => {
+ describe('constructor', () => {
let integrationSettingsForm;
beforeEach(() => {
@@ -24,16 +26,10 @@ describe('IntegrationSettingsForm', () => {
expect(integrationSettingsForm.$form).toBeDefined();
expect(integrationSettingsForm.$form.prop('nodeName')).toEqual('FORM');
expect(integrationSettingsForm.formActive).toBeDefined();
-
- // Form Child Elements
- expect(integrationSettingsForm.$submitBtn).toBeDefined();
- expect(integrationSettingsForm.$submitBtnLoader).toBeDefined();
- expect(integrationSettingsForm.$submitBtnLabel).toBeDefined();
});
it('should initialize form metadata on class object', () => {
expect(integrationSettingsForm.testEndPoint).toBeDefined();
- expect(integrationSettingsForm.canTestService).toBeDefined();
});
});
@@ -59,69 +55,6 @@ describe('IntegrationSettingsForm', () => {
});
});
- describe('toggleSubmitBtnLabel', () => {
- let integrationSettingsForm;
-
- beforeEach(() => {
- integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- });
-
- it('should set Save button label to "Test settings and save changes" when serviceActive & canTestService are `true`', () => {
- integrationSettingsForm.canTestService = true;
- integrationSettingsForm.formActive = true;
-
- integrationSettingsForm.toggleSubmitBtnLabel();
-
- expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual(
- 'Test settings and save changes',
- );
- });
-
- it('should set Save button label to "Save changes" when either serviceActive or canTestService (or both) is `false`', () => {
- integrationSettingsForm.canTestService = false;
- integrationSettingsForm.formActive = false;
-
- integrationSettingsForm.toggleSubmitBtnLabel();
-
- expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes');
-
- integrationSettingsForm.formActive = true;
-
- integrationSettingsForm.toggleSubmitBtnLabel();
-
- expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes');
-
- integrationSettingsForm.canTestService = true;
- integrationSettingsForm.formActive = false;
-
- integrationSettingsForm.toggleSubmitBtnLabel();
-
- expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes');
- });
- });
-
- describe('toggleSubmitBtnState', () => {
- let integrationSettingsForm;
-
- beforeEach(() => {
- integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- });
-
- it('should disable Save button and show loader animation when called with `true`', () => {
- integrationSettingsForm.toggleSubmitBtnState(true);
-
- expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeTruthy();
- expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeFalsy();
- });
-
- it('should enable Save button and hide loader animation when called with `false`', () => {
- integrationSettingsForm.toggleSubmitBtnState(false);
-
- expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeFalsy();
- expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeTruthy();
- });
- });
-
describe('testSettings', () => {
let integrationSettingsForm;
let formData;
@@ -133,6 +66,8 @@ describe('IntegrationSettingsForm', () => {
jest.spyOn(axios, 'put');
integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ integrationSettingsForm.init();
+
// eslint-disable-next-line no-jquery/no-serialize
formData = integrationSettingsForm.$form.serialize();
});
@@ -141,128 +76,60 @@ describe('IntegrationSettingsForm', () => {
mock.restore();
});
- it('should make an ajax request with provided `formData`', () => {
- return integrationSettingsForm.testSettings(formData).then(() => {
- expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData);
- });
- });
-
- it('should show error Flash with `Save anyway` action if ajax request responds with error in test', () => {
- const errorMessage = 'Test failed.';
- mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: true,
- message: errorMessage,
- service_response: 'some error',
- test_failed: true,
- });
+ it('should make an ajax request with provided `formData`', async () => {
+ await integrationSettingsForm.testSettings(formData);
- return integrationSettingsForm.testSettings(formData).then(() => {
- const $flashContainer = $('.flash-container');
-
- expect(
- $flashContainer
- .find('.flash-text')
- .text()
- .trim(),
- ).toEqual('Test failed. some error');
-
- expect($flashContainer.find('.flash-action')).toBeDefined();
- expect(
- $flashContainer
- .find('.flash-action')
- .text()
- .trim(),
- ).toEqual('Save anyway');
- });
+ expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData);
});
- it('should not show error Flash with `Save anyway` action if ajax request responds with error in validation', () => {
- const errorMessage = 'Validations failed.';
- mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: true,
- message: errorMessage,
- service_response: 'some error',
- test_failed: false,
- });
-
- return integrationSettingsForm.testSettings(formData).then(() => {
- const $flashContainer = $('.flash-container');
-
- expect(
- $flashContainer
- .find('.flash-text')
- .text()
- .trim(),
- ).toEqual('Validations failed. some error');
-
- expect($flashContainer.find('.flash-action')).toBeDefined();
- expect(
- $flashContainer
- .find('.flash-action')
- .text()
- .trim(),
- ).toEqual('');
- });
- });
-
- it('should submit form if ajax request responds without any error in test', () => {
+ it('should show success message if test is successful', async () => {
jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {});
mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: false,
});
- return integrationSettingsForm.testSettings(formData).then(() => {
- expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
- });
- });
+ await integrationSettingsForm.testSettings(formData);
- it('should submit form when clicked on `Save anyway` action of error Flash', () => {
- jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {});
+ expect(toast).toHaveBeenCalledWith('Connection successful.');
+ });
+ it('should show error message if ajax request responds with test error', async () => {
const errorMessage = 'Test failed.';
+ const serviceResponse = 'some error';
+
mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: true,
message: errorMessage,
- test_failed: true,
+ service_response: serviceResponse,
+ test_failed: false,
});
- return integrationSettingsForm
- .testSettings(formData)
- .then(() => {
- const $flashAction = $('.flash-container .flash-action');
+ await integrationSettingsForm.testSettings(formData);
- expect($flashAction).toBeDefined();
-
- $flashAction.get(0).click();
- })
- .then(() => {
- expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
- });
+ expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`);
});
- it('should show error Flash if ajax request failed', () => {
+ it('should show error message if ajax request failed', async () => {
const errorMessage = 'Something went wrong on our end.';
mock.onPut(integrationSettingsForm.testEndPoint).networkError();
- return integrationSettingsForm.testSettings(formData).then(() => {
- expect(
- $('.flash-container .flash-text')
- .text()
- .trim(),
- ).toEqual(errorMessage);
- });
+ await integrationSettingsForm.testSettings(formData);
+
+ expect(toast).toHaveBeenCalledWith(errorMessage);
});
- it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => {
+ it('should always dispatch `setIsTesting` with `false` once request is completed', async () => {
+ const dispatchSpy = jest.fn();
+
mock.onPut(integrationSettingsForm.testEndPoint).networkError();
- jest.spyOn(integrationSettingsForm, 'toggleSubmitBtnState').mockImplementation(() => {});
+ integrationSettingsForm.vue.$store = { dispatch: dispatchSpy };
- return integrationSettingsForm.testSettings(formData).then(() => {
- expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false);
- });
+ await integrationSettingsForm.testSettings(formData);
+
+ expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', 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
new file mode 100644
index 00000000000..bfbe4ec8e70
--- /dev/null
+++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
@@ -0,0 +1,289 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import { issuableTypesMap, linkedIssueTypesMap, PathIdSeparator } from '~/related_issues/constants';
+import AddIssuableForm from '~/related_issues/components/add_issuable_form.vue';
+
+const issuable1 = {
+ id: 200,
+ reference: 'foo/bar#123',
+ displayReference: '#123',
+ title: 'some title',
+ path: '/foo/bar/issues/123',
+ state: 'opened',
+};
+
+const issuable2 = {
+ id: 201,
+ reference: 'foo/bar#124',
+ displayReference: '#124',
+ title: 'some other thing',
+ path: '/foo/bar/issues/124',
+ state: 'opened',
+};
+
+const pathIdSeparator = PathIdSeparator.Issue;
+
+const findFormInput = wrapper => wrapper.find('.js-add-issuable-form-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();
+
+ wrapper.destroy();
+ });
+
+ describe('with data', () => {
+ describe('without references', () => {
+ describe('without any input text', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(AddIssuableForm, {
+ propsData: {
+ inputValue: '',
+ pendingReferences: [],
+ pathIdSeparator,
+ },
+ });
+ });
+
+ it('should have disabled submit button', () => {
+ expect(wrapper.vm.$refs.addButton.disabled).toBe(true);
+ expect(wrapper.vm.$refs.loadingIcon).toBeUndefined();
+ });
+ });
+
+ describe('with input text', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(AddIssuableForm, {
+ propsData: {
+ inputValue: 'foo',
+ pendingReferences: [],
+ pathIdSeparator,
+ },
+ });
+ });
+
+ it('should not have disabled submit button', () => {
+ expect(wrapper.vm.$refs.addButton.disabled).toBe(false);
+ });
+ });
+ });
+
+ describe('with references', () => {
+ const inputValue = 'foo #123';
+
+ beforeEach(() => {
+ wrapper = mount(AddIssuableForm, {
+ propsData: {
+ inputValue,
+ pendingReferences: [issuable1.reference, issuable2.reference],
+ pathIdSeparator,
+ },
+ });
+ });
+
+ it('should put input value in place', () => {
+ expect(findFormInput(wrapper).value).toEqual(inputValue);
+ });
+
+ it('should render pending issuables items', () => {
+ expect(wrapper.findAll('.js-add-issuable-form-token-list-item').length).toEqual(2);
+ });
+
+ it('should not have disabled submit button', () => {
+ expect(wrapper.vm.$refs.addButton.disabled).toBe(false);
+ });
+ });
+
+ describe('when issuable type is "issue"', () => {
+ beforeEach(() => {
+ wrapper = mount(AddIssuableForm, {
+ propsData: {
+ inputValue: '',
+ issuableType: issuableTypesMap.ISSUE,
+ pathIdSeparator,
+ pendingReferences: [],
+ },
+ });
+ });
+
+ it('does not show radio inputs', () => {
+ expect(findRadioInputs(wrapper).length).toBe(0);
+ });
+ });
+
+ describe('when issuable type is "epic"', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(AddIssuableForm, {
+ propsData: {
+ inputValue: '',
+ issuableType: issuableTypesMap.EPIC,
+ pathIdSeparator,
+ pendingReferences: [],
+ },
+ });
+ });
+
+ it('does not show radio inputs', () => {
+ expect(findRadioInputs(wrapper).length).toBe(0);
+ });
+ });
+
+ describe('when it is a Linked Issues form', () => {
+ beforeEach(() => {
+ wrapper = mount(AddIssuableForm, {
+ propsData: {
+ inputValue: '',
+ showCategorizedIssues: true,
+ issuableType: issuableTypesMap.ISSUE,
+ pathIdSeparator,
+ pendingReferences: [],
+ },
+ });
+ });
+
+ it('shows radio inputs to allow categorisation of blocking issues', () => {
+ expect(findRadioInputs(wrapper).length).toBeGreaterThan(0);
+ });
+
+ describe('form radio buttons', () => {
+ let radioInputs;
+
+ beforeEach(() => {
+ radioInputs = findRadioInputs(wrapper);
+ });
+
+ it('shows "relates to" option', () => {
+ expect(findRadioInput(radioInputs, linkedIssueTypesMap.RELATES_TO)).not.toBeNull();
+ });
+
+ it('shows "blocks" option', () => {
+ expect(findRadioInput(radioInputs, linkedIssueTypesMap.BLOCKS)).not.toBeNull();
+ });
+
+ it('shows "is blocked by" option', () => {
+ expect(findRadioInput(radioInputs, linkedIssueTypesMap.IS_BLOCKED_BY)).not.toBeNull();
+ });
+
+ it('shows 3 options in total', () => {
+ expect(radioInputs.length).toBe(3);
+ });
+ });
+
+ describe('when the form is submitted', () => {
+ it('emits an event with a "relates_to" link type when the "relates to" radio input selected', done => {
+ jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {});
+
+ wrapper.vm.linkedIssueType = linkedIssueTypesMap.RELATES_TO;
+ wrapper.vm.onFormSubmit();
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
+ pendingReferences: '',
+ linkedIssueType: linkedIssueTypesMap.RELATES_TO,
+ });
+ done();
+ });
+ });
+
+ it('emits an event with a "blocks" link type when the "blocks" radio input selected', done => {
+ jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {});
+
+ wrapper.vm.linkedIssueType = linkedIssueTypesMap.BLOCKS;
+ wrapper.vm.onFormSubmit();
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
+ pendingReferences: '',
+ linkedIssueType: linkedIssueTypesMap.BLOCKS,
+ });
+ done();
+ });
+ });
+
+ it('emits an event with a "is_blocked_by" link type when the "is blocked by" radio input selected', done => {
+ jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {});
+
+ wrapper.vm.linkedIssueType = linkedIssueTypesMap.IS_BLOCKED_BY;
+ wrapper.vm.onFormSubmit();
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
+ pendingReferences: '',
+ linkedIssueType: linkedIssueTypesMap.IS_BLOCKED_BY,
+ });
+ done();
+ });
+ });
+
+ it('shows error message when error is present', done => {
+ const itemAddFailureMessage = 'Something went wrong while submitting.';
+ wrapper.setProps({
+ hasError: true,
+ itemAddFailureMessage,
+ });
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.gl-field-error').exists()).toBe(true);
+ expect(wrapper.find('.gl-field-error').text()).toContain(itemAddFailureMessage);
+ done();
+ });
+ });
+ });
+ });
+ });
+
+ describe('computed', () => {
+ describe('transformedAutocompleteSources', () => {
+ const autoCompleteSources = {
+ issues: 'http://localhost/autocomplete/issues',
+ epics: 'http://localhost/autocomplete/epics',
+ };
+
+ it('returns autocomplete object', () => {
+ wrapper = constructWrapper({
+ autoCompleteSources,
+ });
+
+ expect(wrapper.vm.transformedAutocompleteSources).toBe(autoCompleteSources);
+
+ wrapper = constructWrapper({
+ autoCompleteSources,
+ confidential: false,
+ });
+
+ expect(wrapper.vm.transformedAutocompleteSources).toBe(autoCompleteSources);
+ });
+
+ it('returns autocomplete sources with query `confidential_only`, when it is confidential', () => {
+ wrapper = constructWrapper({
+ autoCompleteSources,
+ confidential: true,
+ });
+
+ const actualSources = wrapper.vm.transformedAutocompleteSources;
+
+ 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
new file mode 100644
index 00000000000..553721fa783
--- /dev/null
+++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
@@ -0,0 +1,241 @@
+import Vue from 'vue';
+import { PathIdSeparator } from '~/related_issues/constants';
+import issueToken from '~/related_issues/components/issue_token.vue';
+
+describe('IssueToken', () => {
+ const idKey = 200;
+ const displayReference = 'foo/bar#123';
+ const title = 'some title';
+ const pathIdSeparator = PathIdSeparator.Issue;
+ const eventNamespace = 'pendingIssuable';
+ let IssueToken;
+ let vm;
+
+ beforeEach(() => {
+ IssueToken = Vue.extend(issueToken);
+ });
+
+ afterEach(() => {
+ if (vm) {
+ vm.$destroy();
+ }
+ });
+
+ describe('with reference supplied', () => {
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ },
+ }).$mount();
+ });
+
+ it('shows reference', () => {
+ expect(vm.$el.textContent.trim()).toEqual(displayReference);
+ });
+
+ it('does not link without path specified', () => {
+ expect(vm.$refs.link.tagName.toLowerCase()).toEqual('span');
+ expect(vm.$refs.link.getAttribute('href')).toBeNull();
+ });
+ });
+
+ describe('with reference and title supplied', () => {
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ title,
+ },
+ }).$mount();
+ });
+
+ it('shows reference and title', () => {
+ expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
+ expect(vm.$refs.title.textContent.trim()).toEqual(title);
+ });
+ });
+
+ describe('with path supplied', () => {
+ const path = '/foo/bar/issues/123';
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ title,
+ path,
+ },
+ }).$mount();
+ });
+
+ it('links reference and title', () => {
+ expect(vm.$refs.link.getAttribute('href')).toEqual(path);
+ });
+ });
+
+ describe('with state supplied', () => {
+ describe("`state: 'opened'`", () => {
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ state: 'opened',
+ },
+ }).$mount();
+ });
+
+ it('shows green circle icon', () => {
+ expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined();
+ });
+ });
+
+ describe("`state: 'reopened'`", () => {
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ state: 'reopened',
+ },
+ }).$mount();
+ });
+
+ it('shows green circle icon', () => {
+ expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined();
+ });
+ });
+
+ describe("`state: 'closed'`", () => {
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ state: 'closed',
+ },
+ }).$mount();
+ });
+
+ it('shows red minus icon', () => {
+ expect(vm.$el.querySelector('.issue-token-state-icon-closed.fa.fa-minus')).toBeDefined();
+ });
+ });
+ });
+
+ describe('with reference, title, state', () => {
+ const state = 'opened';
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ title,
+ state,
+ },
+ }).$mount();
+ });
+
+ it('shows reference, title, and state', () => {
+ const stateIcon = vm.$refs.reference.querySelector('svg');
+
+ expect(stateIcon.getAttribute('aria-label')).toEqual(state);
+ expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
+ expect(vm.$refs.title.textContent.trim()).toEqual(title);
+ });
+ });
+
+ describe('with canRemove', () => {
+ describe('`canRemove: false` (default)', () => {
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ },
+ }).$mount();
+ });
+
+ it('does not have remove button', () => {
+ expect(vm.$el.querySelector('.issue-token-remove-button')).toBeNull();
+ });
+ });
+
+ describe('`canRemove: true`', () => {
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ canRemove: true,
+ },
+ }).$mount();
+ });
+
+ it('has remove button', () => {
+ expect(vm.$el.querySelector('.issue-token-remove-button')).toBeDefined();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ },
+ }).$mount();
+ });
+
+ it('when getting checked', () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ vm.onRemoveRequest();
+
+ expect(vm.$emit).toHaveBeenCalledWith('pendingIssuableRemoveRequest', vm.idKey);
+ });
+ });
+
+ describe('tooltip', () => {
+ beforeEach(() => {
+ vm = new IssueToken({
+ propsData: {
+ idKey,
+ eventNamespace,
+ displayReference,
+ pathIdSeparator,
+ canRemove: true,
+ },
+ }).$mount();
+ });
+
+ it('should not be escaped', () => {
+ const { originalTitle } = vm.$refs.removeButton.dataset;
+
+ expect(originalTitle).toEqual(`Remove ${displayReference}`);
+ });
+ });
+});
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
new file mode 100644
index 00000000000..0f88e4d71fe
--- /dev/null
+++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
@@ -0,0 +1,206 @@
+import { shallowMount, mount } from '@vue/test-utils';
+import { GlButton, GlIcon } from '@gitlab/ui';
+import {
+ issuable1,
+ issuable2,
+ issuable3,
+} from 'jest/vue_shared/components/issue/related_issuable_mock_data';
+import RelatedIssuesBlock from '~/related_issues/components/related_issues_block.vue';
+import {
+ linkedIssueTypesMap,
+ linkedIssueTypesTextMap,
+ PathIdSeparator,
+} from '~/related_issues/constants';
+
+describe('RelatedIssuesBlock', () => {
+ let wrapper;
+
+ const findIssueCountBadgeAddButton = () => wrapper.find(GlButton);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with defaults', () => {
+ beforeEach(() => {
+ wrapper = mount(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType: 'issue',
+ },
+ });
+ });
+
+ it('displays "Linked issues" in the header', () => {
+ expect(wrapper.find('.card-title').text()).toContain('Linked issues');
+ });
+
+ it('unable to add new related issues', () => {
+ expect(findIssueCountBadgeAddButton().exists()).toBe(false);
+ });
+
+ it('add related issues form is hidden', () => {
+ expect(wrapper.find('.js-add-related-issues-form-area').exists()).toBe(false);
+ });
+ });
+
+ describe('with headerText slot', () => {
+ it('displays header text slot data', () => {
+ const headerText = '<div>custom header text</div>';
+
+ wrapper = shallowMount(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType: 'issue',
+ },
+ slots: { headerText },
+ });
+
+ expect(wrapper.find('.card-title').html()).toContain(headerText);
+ });
+ });
+
+ describe('with headerActions slot', () => {
+ it('displays header actions slot data', () => {
+ const headerActions = '<button data-testid="custom-button">custom button</button>';
+
+ wrapper = shallowMount(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType: 'issue',
+ },
+ slots: { headerActions },
+ });
+
+ expect(wrapper.find('[data-testid="custom-button"]').html()).toBe(headerActions);
+ });
+ });
+
+ describe('with isFetching=true', () => {
+ beforeEach(() => {
+ wrapper = mount(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ isFetching: true,
+ issuableType: 'issue',
+ },
+ });
+ });
+
+ it('should show `...` badge count', () => {
+ expect(wrapper.vm.badgeLabel).toBe('...');
+ });
+ });
+
+ describe('with canAddRelatedIssues=true', () => {
+ beforeEach(() => {
+ wrapper = mount(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ canAdmin: true,
+ issuableType: 'issue',
+ },
+ });
+ });
+
+ it('can add new related issues', () => {
+ expect(findIssueCountBadgeAddButton().exists()).toBe(true);
+ });
+ });
+
+ describe('with isFormVisible=true', () => {
+ beforeEach(() => {
+ wrapper = mount(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ isFormVisible: true,
+ issuableType: 'issue',
+ },
+ });
+ });
+
+ it('shows add related issues form', () => {
+ expect(wrapper.find('.js-add-related-issues-form-area').exists()).toBe(true);
+ });
+ });
+
+ describe('showCategorizedIssues prop', () => {
+ 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 = mount(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ relatedIssues: [issuable1, issuable2, issuable3],
+ issuableType: 'issue',
+ showCategorizedIssues,
+ },
+ });
+ };
+
+ describe('when showCategorizedIssues=true', () => {
+ beforeEach(() => mountComponent(true));
+
+ it('should render issue tokens items', () => {
+ expect(issueList()).toHaveLength(3);
+ });
+
+ it('shows "Blocks" heading', () => {
+ const blocks = linkedIssueTypesTextMap[linkedIssueTypesMap.BLOCKS];
+
+ expect(headingTextAt(0)).toBe(blocks);
+ });
+
+ it('shows "Is blocked by" heading', () => {
+ const isBlockedBy = linkedIssueTypesTextMap[linkedIssueTypesMap.IS_BLOCKED_BY];
+
+ expect(headingTextAt(1)).toBe(isBlockedBy);
+ });
+
+ it('shows "Relates to" heading', () => {
+ const relatesTo = linkedIssueTypesTextMap[linkedIssueTypesMap.RELATES_TO];
+
+ expect(headingTextAt(2)).toBe(relatesTo);
+ });
+ });
+
+ describe('when showCategorizedIssues=false', () => {
+ it('should render issues as a flat list with no header', () => {
+ mountComponent(false);
+
+ expect(issueList()).toHaveLength(3);
+ expect(categorizedHeadings()).toHaveLength(0);
+ });
+ });
+ });
+
+ describe('renders correct icon when', () => {
+ [
+ {
+ icon: 'issues',
+ issuableType: 'issue',
+ },
+ {
+ icon: 'epic',
+ issuableType: 'epic',
+ },
+ ].forEach(({ issuableType, icon }) => {
+ it(`issuableType=${issuableType} is passed`, () => {
+ wrapper = shallowMount(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType,
+ },
+ });
+
+ const iconComponent = wrapper.find(GlIcon);
+ expect(iconComponent.exists()).toBe(true);
+ expect(iconComponent.props('name')).toBe(icon);
+ });
+ });
+ });
+});
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
new file mode 100644
index 00000000000..6cf0b9d21ea
--- /dev/null
+++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
@@ -0,0 +1,190 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import {
+ issuable1,
+ issuable2,
+ issuable3,
+ issuable4,
+ issuable5,
+} from 'jest/vue_shared/components/issue/related_issuable_mock_data';
+import IssueDueDate from '~/boards/components/issue_due_date.vue';
+import RelatedIssuesList from '~/related_issues/components/related_issues_list.vue';
+import { PathIdSeparator } from '~/related_issues/constants';
+
+describe('RelatedIssuesList', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with defaults', () => {
+ const heading = 'Related to';
+
+ beforeEach(() => {
+ wrapper = shallowMount(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType: 'issue',
+ heading,
+ },
+ });
+ });
+
+ it('shows a heading', () => {
+ expect(wrapper.find('h4').text()).toContain(heading);
+ });
+
+ it('should not show loading icon', () => {
+ expect(wrapper.vm.$refs.loadingIcon).toBeUndefined();
+ });
+ });
+
+ describe('with isFetching=true', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ isFetching: true,
+ issuableType: 'issue',
+ },
+ });
+ });
+
+ it('should show loading icon', () => {
+ expect(wrapper.vm.$refs.loadingIcon).toBeDefined();
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5],
+ issuableType: 'issue',
+ },
+ });
+ });
+
+ it('updates the order correctly when an item is moved to the top', () => {
+ const beforeAfterIds = wrapper.vm.getBeforeAfterId(
+ wrapper.vm.$el.querySelector('ul li:first-child'),
+ );
+
+ expect(beforeAfterIds.beforeId).toBeNull();
+ expect(beforeAfterIds.afterId).toBe(2);
+ });
+
+ it('updates the order correctly when an item is moved to the bottom', () => {
+ const beforeAfterIds = wrapper.vm.getBeforeAfterId(
+ wrapper.vm.$el.querySelector('ul li:last-child'),
+ );
+
+ expect(beforeAfterIds.beforeId).toBe(4);
+ expect(beforeAfterIds.afterId).toBeNull();
+ });
+
+ it('updates the order correctly when an item is swapped with adjacent item', () => {
+ const beforeAfterIds = wrapper.vm.getBeforeAfterId(
+ wrapper.vm.$el.querySelector('ul li:nth-child(3)'),
+ );
+
+ expect(beforeAfterIds.beforeId).toBe(2);
+ expect(beforeAfterIds.afterId).toBe(4);
+ });
+
+ it('updates the order correctly when an item is moved somewhere in the middle', () => {
+ const beforeAfterIds = wrapper.vm.getBeforeAfterId(
+ wrapper.vm.$el.querySelector('ul li:nth-child(4)'),
+ );
+
+ expect(beforeAfterIds.beforeId).toBe(3);
+ expect(beforeAfterIds.afterId).toBe(5);
+ });
+ });
+
+ describe('issuableOrderingId returns correct issuable order id when', () => {
+ it('issuableType is epic', () => {
+ wrapper = shallowMount(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType: 'issue',
+ },
+ });
+
+ expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.epicIssueId);
+ });
+
+ it('issuableType is issue', () => {
+ wrapper = shallowMount(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType: 'epic',
+ },
+ });
+
+ expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.id);
+ });
+ });
+
+ describe('renders correct ordering id when', () => {
+ let relatedIssues;
+
+ beforeAll(() => {
+ relatedIssues = [issuable1, issuable2, issuable3, issuable4, issuable5];
+ });
+
+ it('issuableType is epic', () => {
+ wrapper = shallowMount(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType: 'epic',
+ relatedIssues,
+ },
+ });
+
+ const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
+
+ Array.from(listItems).forEach((item, index) => {
+ expect(Number(item.dataset.orderingId)).toBe(relatedIssues[index].id);
+ });
+ });
+
+ it('issuableType is issue', () => {
+ wrapper = shallowMount(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType: 'issue',
+ relatedIssues,
+ },
+ });
+
+ const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
+
+ Array.from(listItems).forEach((item, index) => {
+ expect(Number(item.dataset.orderingId)).toBe(relatedIssues[index].epicIssueId);
+ });
+ });
+ });
+
+ describe('related item contents', () => {
+ beforeAll(() => {
+ wrapper = mount(RelatedIssuesList, {
+ propsData: {
+ issuableType: 'issue',
+ pathIdSeparator: PathIdSeparator.Issue,
+ relatedIssues: [issuable1],
+ },
+ });
+ });
+
+ it('shows due date', () => {
+ expect(
+ wrapper
+ .find(IssueDueDate)
+ .find('.board-card-info-text')
+ .text(),
+ ).toBe('Nov 22, 2010');
+ });
+ });
+});
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
new file mode 100644
index 00000000000..2544d0bd030
--- /dev/null
+++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
@@ -0,0 +1,341 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import {
+ defaultProps,
+ issuable1,
+ issuable2,
+} from 'jest/vue_shared/components/issue/related_issuable_mock_data';
+import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
+import relatedIssuesService from '~/related_issues/services/related_issues_service';
+import { linkedIssueTypesMap } from '~/related_issues/constants';
+import axios from '~/lib/utils/axios_utils';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+
+jest.mock('~/flash');
+
+describe('RelatedIssuesRoot', () => {
+ let wrapper;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(defaultProps.endpoint).reply(200, []);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const createComponent = (mountFn = mount) => {
+ wrapper = mountFn(RelatedIssuesRoot, {
+ propsData: defaultProps,
+ });
+
+ // Wait for fetch request `fetchRelatedIssues` to complete before starting to test
+ return waitForPromises();
+ };
+
+ describe('methods', () => {
+ describe('onRelatedIssueRemoveRequest', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues')
+ .mockReturnValue(Promise.reject());
+
+ return createComponent().then(() => {
+ wrapper.vm.store.setRelatedIssues([issuable1]);
+ });
+ });
+
+ it('remove related issue and succeeds', () => {
+ mock.onDelete(issuable1.referencePath).reply(200, { issues: [] });
+
+ wrapper.vm.onRelatedIssueRemoveRequest(issuable1.id);
+
+ return axios.waitForAll().then(() => {
+ expect(wrapper.vm.state.relatedIssues).toEqual([]);
+ });
+ });
+
+ it('remove related issue, fails, and restores to related issues', () => {
+ mock.onDelete(issuable1.referencePath).reply(422, {});
+
+ wrapper.vm.onRelatedIssueRemoveRequest(issuable1.id);
+
+ return axios.waitForAll().then(() => {
+ expect(wrapper.vm.state.relatedIssues).toHaveLength(1);
+ expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id);
+ });
+ });
+ });
+
+ describe('onToggleAddRelatedIssuesForm', () => {
+ beforeEach(() => createComponent(shallowMount));
+
+ it('toggle related issues form to visible', () => {
+ wrapper.vm.onToggleAddRelatedIssuesForm();
+
+ expect(wrapper.vm.isFormVisible).toEqual(true);
+ });
+
+ it('show add related issues form to hidden', () => {
+ wrapper.vm.isFormVisible = true;
+
+ wrapper.vm.onToggleAddRelatedIssuesForm();
+
+ expect(wrapper.vm.isFormVisible).toEqual(false);
+ });
+ });
+
+ describe('onPendingIssueRemoveRequest', () => {
+ beforeEach(() =>
+ createComponent().then(() => {
+ wrapper.vm.store.setPendingReferences([issuable1.reference]);
+ }),
+ );
+
+ it('remove pending related issue', () => {
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
+
+ wrapper.vm.onPendingIssueRemoveRequest(0);
+
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
+ });
+ });
+
+ describe('onPendingFormSubmit', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues')
+ .mockReturnValue(Promise.reject());
+
+ return createComponent().then(() => {
+ jest.spyOn(wrapper.vm, 'processAllReferences');
+ jest.spyOn(wrapper.vm.service, 'addRelatedIssues');
+ createFlash.mockClear();
+ });
+ });
+
+ it('processes references before submitting', () => {
+ const input = '#123';
+ const linkedIssueType = linkedIssueTypesMap.RELATES_TO;
+ const emitObj = {
+ pendingReferences: input,
+ linkedIssueType,
+ };
+
+ wrapper.vm.onPendingFormSubmit(emitObj);
+
+ expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input);
+ expect(wrapper.vm.service.addRelatedIssues).toHaveBeenCalledWith([input], linkedIssueType);
+ });
+
+ it('submit zero pending issue as related issue', () => {
+ wrapper.vm.store.setPendingReferences([]);
+ wrapper.vm.onPendingFormSubmit({});
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
+ expect(wrapper.vm.state.relatedIssues).toHaveLength(0);
+ });
+ });
+
+ it('submit pending issue as related issue', () => {
+ mock.onPost(defaultProps.endpoint).reply(200, {
+ issuables: [issuable1],
+ result: {
+ message: 'something was successfully related',
+ status: 'success',
+ },
+ });
+
+ wrapper.vm.store.setPendingReferences([issuable1.reference]);
+ wrapper.vm.onPendingFormSubmit({});
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
+ expect(wrapper.vm.state.relatedIssues).toHaveLength(1);
+ expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id);
+ });
+ });
+
+ it('submit multiple pending issues as related issues', () => {
+ mock.onPost(defaultProps.endpoint).reply(200, {
+ issuables: [issuable1, issuable2],
+ result: {
+ message: 'something was successfully related',
+ status: 'success',
+ },
+ });
+
+ wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]);
+ wrapper.vm.onPendingFormSubmit({});
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
+ expect(wrapper.vm.state.relatedIssues).toHaveLength(2);
+ expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id);
+ expect(wrapper.vm.state.relatedIssues[1].id).toEqual(issuable2.id);
+ });
+ });
+
+ it('displays a message from the backend upon error', () => {
+ const input = '#123';
+ const message = 'error';
+
+ mock.onPost(defaultProps.endpoint).reply(409, { message });
+ wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]);
+
+ expect(createFlash).not.toHaveBeenCalled();
+ wrapper.vm.onPendingFormSubmit(input);
+
+ return waitForPromises().then(() => {
+ expect(createFlash).toHaveBeenCalledWith(message);
+ });
+ });
+ });
+
+ describe('onPendingFormCancel', () => {
+ beforeEach(() =>
+ createComponent().then(() => {
+ wrapper.vm.isFormVisible = true;
+ wrapper.vm.inputValue = 'foo';
+ }),
+ );
+
+ it('when canceling and hiding add issuable form', () => {
+ wrapper.vm.onPendingFormCancel();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.isFormVisible).toEqual(false);
+ expect(wrapper.vm.inputValue).toEqual('');
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
+ });
+ });
+ });
+
+ describe('fetchRelatedIssues', () => {
+ beforeEach(() => createComponent());
+
+ it('sets isFetching while fetching', () => {
+ wrapper.vm.fetchRelatedIssues();
+
+ expect(wrapper.vm.isFetching).toEqual(true);
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.isFetching).toEqual(false);
+ });
+ });
+
+ it('should fetch related issues', () => {
+ mock.onGet(defaultProps.endpoint).reply(200, [issuable1, issuable2]);
+
+ wrapper.vm.fetchRelatedIssues();
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.state.relatedIssues).toHaveLength(2);
+ expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id);
+ expect(wrapper.vm.state.relatedIssues[1].id).toEqual(issuable2.id);
+ });
+ });
+ });
+
+ describe('onInput', () => {
+ beforeEach(() => createComponent());
+
+ it('fill in issue number reference and adds to pending related issues', () => {
+ const input = '#123 ';
+ wrapper.vm.onInput({
+ untouchedRawReferences: [input.trim()],
+ touchedReference: input,
+ });
+
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
+ expect(wrapper.vm.state.pendingReferences[0]).toEqual('#123');
+ });
+
+ it('fill in with full reference', () => {
+ const input = 'asdf/qwer#444 ';
+ wrapper.vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input });
+
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
+ expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
+ });
+
+ it('fill in with issue link', () => {
+ const link = 'http://localhost:3000/foo/bar/issues/111';
+ const input = `${link} `;
+ wrapper.vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input });
+
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
+ expect(wrapper.vm.state.pendingReferences[0]).toEqual(link);
+ });
+
+ it('fill in with multiple references', () => {
+ const input = 'asdf/qwer#444 #12 ';
+ wrapper.vm.onInput({
+ untouchedRawReferences: input.trim().split(/\s/),
+ touchedReference: 2,
+ });
+
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(2);
+ expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
+ expect(wrapper.vm.state.pendingReferences[1]).toEqual('#12');
+ });
+
+ it('fill in with some invalid things', () => {
+ const input = 'something random ';
+ wrapper.vm.onInput({
+ untouchedRawReferences: input.trim().split(/\s/),
+ touchedReference: 2,
+ });
+
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(2);
+ expect(wrapper.vm.state.pendingReferences[0]).toEqual('something');
+ expect(wrapper.vm.state.pendingReferences[1]).toEqual('random');
+ });
+ });
+
+ describe('onBlur', () => {
+ beforeEach(() =>
+ createComponent().then(() => {
+ jest.spyOn(wrapper.vm, 'processAllReferences').mockImplementation(() => {});
+ }),
+ );
+
+ it('add any references to pending when blurring', () => {
+ const input = '#123';
+
+ wrapper.vm.onBlur(input);
+
+ expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input);
+ });
+ });
+
+ describe('processAllReferences', () => {
+ beforeEach(() => createComponent());
+
+ it('add valid reference to pending', () => {
+ const input = '#123';
+ wrapper.vm.processAllReferences(input);
+
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
+ expect(wrapper.vm.state.pendingReferences[0]).toEqual('#123');
+ });
+
+ it('add any valid references to pending', () => {
+ const input = 'asdf #123';
+ wrapper.vm.processAllReferences(input);
+
+ expect(wrapper.vm.state.pendingReferences).toHaveLength(2);
+ expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf');
+ expect(wrapper.vm.state.pendingReferences[1]).toEqual('#123');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js b/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js
new file mode 100644
index 00000000000..ada1c44560f
--- /dev/null
+++ b/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js
@@ -0,0 +1,111 @@
+import {
+ issuable1,
+ issuable2,
+ issuable3,
+ issuable4,
+ issuable5,
+} from 'jest/vue_shared/components/issue/related_issuable_mock_data';
+import RelatedIssuesStore from '~/related_issues/stores/related_issues_store';
+
+describe('RelatedIssuesStore', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new RelatedIssuesStore();
+ });
+
+ describe('setRelatedIssues', () => {
+ it('defaults to empty array', () => {
+ expect(store.state.relatedIssues).toEqual([]);
+ });
+
+ it('sets issues', () => {
+ const relatedIssues = [issuable1];
+ store.setRelatedIssues(relatedIssues);
+
+ expect(store.state.relatedIssues).toEqual(relatedIssues);
+ });
+ });
+
+ describe('addRelatedIssues', () => {
+ it('adds related issues', () => {
+ store.state.relatedIssues = [issuable1];
+ store.addRelatedIssues([issuable2, issuable3]);
+
+ expect(store.state.relatedIssues).toEqual([issuable1, issuable2, issuable3]);
+ });
+ });
+
+ describe('removeRelatedIssue', () => {
+ it('removes issue', () => {
+ store.state.relatedIssues = [issuable1];
+
+ store.removeRelatedIssue(issuable1);
+
+ expect(store.state.relatedIssues).toEqual([]);
+ });
+
+ it('removes issue with multiple in store', () => {
+ store.state.relatedIssues = [issuable1, issuable2];
+
+ store.removeRelatedIssue(issuable1);
+
+ expect(store.state.relatedIssues).toEqual([issuable2]);
+ });
+ });
+
+ describe('updateIssueOrder', () => {
+ it('updates issue order', () => {
+ store.state.relatedIssues = [issuable1, issuable2, issuable3, issuable4, issuable5];
+
+ expect(store.state.relatedIssues[3].id).toBe(issuable4.id);
+ store.updateIssueOrder(3, 0);
+
+ expect(store.state.relatedIssues[0].id).toBe(issuable4.id);
+ });
+ });
+
+ describe('setPendingReferences', () => {
+ it('defaults to empty array', () => {
+ expect(store.state.pendingReferences).toEqual([]);
+ });
+
+ it('sets pending references', () => {
+ const relatedIssues = [issuable1.reference];
+ store.setPendingReferences(relatedIssues);
+
+ expect(store.state.pendingReferences).toEqual(relatedIssues);
+ });
+ });
+
+ describe('addPendingReferences', () => {
+ it('adds a reference', () => {
+ store.state.pendingReferences = [issuable1.reference];
+ store.addPendingReferences([issuable2.reference, issuable3.reference]);
+
+ expect(store.state.pendingReferences).toEqual([
+ issuable1.reference,
+ issuable2.reference,
+ issuable3.reference,
+ ]);
+ });
+ });
+
+ describe('removePendingRelatedIssue', () => {
+ it('removes issue', () => {
+ store.state.pendingReferences = [issuable1.reference];
+
+ store.removePendingRelatedIssue(0);
+
+ expect(store.state.pendingReferences).toEqual([]);
+ });
+
+ it('removes issue with multiple in store', () => {
+ store.state.pendingReferences = [issuable1.reference, issuable2.reference];
+
+ store.removePendingRelatedIssue(0);
+
+ expect(store.state.pendingReferences).toEqual([issuable2.reference]);
+ });
+ });
+});
diff --git a/spec/frontend/issuable_create/components/issuable_create_root_spec.js b/spec/frontend/issuable_create/components/issuable_create_root_spec.js
new file mode 100644
index 00000000000..675d01ae4af
--- /dev/null
+++ b/spec/frontend/issuable_create/components/issuable_create_root_spec.js
@@ -0,0 +1,64 @@
+import { mount } from '@vue/test-utils';
+
+import IssuableCreateRoot from '~/issuable_create/components/issuable_create_root.vue';
+import IssuableForm from '~/issuable_create/components/issuable_form.vue';
+
+const createComponent = ({
+ descriptionPreviewPath = '/gitlab-org/gitlab-shell/preview_markdown',
+ descriptionHelpPath = '/help/user/markdown',
+ labelsFetchPath = '/gitlab-org/gitlab-shell/-/labels.json',
+ labelsManagePath = '/gitlab-org/gitlab-shell/-/labels',
+} = {}) => {
+ return mount(IssuableCreateRoot, {
+ propsData: {
+ descriptionPreviewPath,
+ descriptionHelpPath,
+ labelsFetchPath,
+ labelsManagePath,
+ },
+ slots: {
+ title: `
+ <h1 class="js-create-title">New Issuable</h1>
+ `,
+ actions: `
+ <button class="js-issuable-save">Submit issuable</button>
+ `,
+ },
+ });
+};
+
+describe('IssuableCreateRoot', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders component container element with class "issuable-create-container"', () => {
+ expect(wrapper.classes()).toContain('issuable-create-container');
+ });
+
+ it('renders contents for slot "title"', () => {
+ const titleEl = wrapper.find('h1.js-create-title');
+
+ expect(titleEl.exists()).toBe(true);
+ expect(titleEl.text()).toBe('New Issuable');
+ });
+
+ it('renders issuable-form component', () => {
+ expect(wrapper.find(IssuableForm).exists()).toBe(true);
+ });
+
+ it('renders contents for slot "actions" within issuable-form component', () => {
+ const buttonEl = wrapper.find(IssuableForm).find('button.js-issuable-save');
+
+ expect(buttonEl.exists()).toBe(true);
+ expect(buttonEl.text()).toBe('Submit issuable');
+ });
+ });
+});
diff --git a/spec/frontend/issuable_create/components/issuable_form_spec.js b/spec/frontend/issuable_create/components/issuable_form_spec.js
new file mode 100644
index 00000000000..e2c6b4d9521
--- /dev/null
+++ b/spec/frontend/issuable_create/components/issuable_form_spec.js
@@ -0,0 +1,119 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlFormInput } from '@gitlab/ui';
+
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
+
+import IssuableForm from '~/issuable_create/components/issuable_form.vue';
+
+const createComponent = ({
+ descriptionPreviewPath = '/gitlab-org/gitlab-shell/preview_markdown',
+ descriptionHelpPath = '/help/user/markdown',
+ labelsFetchPath = '/gitlab-org/gitlab-shell/-/labels.json',
+ labelsManagePath = '/gitlab-org/gitlab-shell/-/labels',
+} = {}) => {
+ return shallowMount(IssuableForm, {
+ propsData: {
+ descriptionPreviewPath,
+ descriptionHelpPath,
+ labelsFetchPath,
+ labelsManagePath,
+ },
+ slots: {
+ actions: `
+ <button class="js-issuable-save">Submit issuable</button>
+ `,
+ },
+ });
+};
+
+describe('IssuableForm', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('methods', () => {
+ describe('handleUpdateSelectedLabels', () => {
+ it('sets provided `labels` param to prop `selectedLabels`', () => {
+ const labels = [
+ {
+ id: 1,
+ color: '#BADA55',
+ text_color: '#ffffff',
+ title: 'Documentation',
+ },
+ ];
+
+ wrapper.vm.handleUpdateSelectedLabels(labels);
+
+ expect(wrapper.vm.selectedLabels).toBe(labels);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders issuable title input field', () => {
+ const titleFieldEl = wrapper.find('[data-testid="issuable-title"]');
+
+ expect(titleFieldEl.exists()).toBe(true);
+ expect(titleFieldEl.find('label').text()).toBe('Title');
+ expect(titleFieldEl.find(GlFormInput).exists()).toBe(true);
+ expect(titleFieldEl.find(GlFormInput).attributes('placeholder')).toBe('Title');
+ expect(titleFieldEl.find(GlFormInput).attributes('autofocus')).toBe('true');
+ });
+
+ it('renders issuable description input field', () => {
+ const descriptionFieldEl = wrapper.find('[data-testid="issuable-description"]');
+
+ expect(descriptionFieldEl.exists()).toBe(true);
+ expect(descriptionFieldEl.find('label').text()).toBe('Description');
+ expect(descriptionFieldEl.find(MarkdownField).exists()).toBe(true);
+ expect(descriptionFieldEl.find(MarkdownField).props()).toMatchObject({
+ markdownPreviewPath: wrapper.vm.descriptionPreviewPath,
+ markdownDocsPath: wrapper.vm.descriptionHelpPath,
+ addSpacingClasses: false,
+ showSuggestPopover: true,
+ });
+ expect(descriptionFieldEl.find('textarea').exists()).toBe(true);
+ expect(descriptionFieldEl.find('textarea').attributes('placeholder')).toBe(
+ 'Write a comment or drag your files here…',
+ );
+ });
+
+ it('renders labels select field', () => {
+ const labelsSelectEl = wrapper.find('[data-testid="issuable-labels"]');
+
+ expect(labelsSelectEl.exists()).toBe(true);
+ expect(labelsSelectEl.find('label').text()).toBe('Labels');
+ expect(labelsSelectEl.find(LabelsSelect).exists()).toBe(true);
+ expect(labelsSelectEl.find(LabelsSelect).props()).toMatchObject({
+ allowLabelEdit: true,
+ allowLabelCreate: true,
+ allowMultiselect: true,
+ allowScopedLabels: true,
+ labelsFetchPath: wrapper.vm.labelsFetchPath,
+ labelsManagePath: wrapper.vm.labelsManagePath,
+ selectedLabels: wrapper.vm.selectedLabels,
+ labelsListTitle: 'Select label',
+ footerCreateLabelTitle: 'Create project label',
+ footerManageLabelTitle: 'Manage project labels',
+ variant: 'embedded',
+ });
+ });
+
+ it('renders contents for slot "actions"', () => {
+ const buttonEl = wrapper
+ .find('[data-testid="issuable-create-actions"]')
+ .find('button.js-issuable-save');
+
+ expect(buttonEl.exists()).toBe(true);
+ expect(buttonEl.text()).toBe('Submit issuable');
+ });
+ });
+});
diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js
new file mode 100644
index 00000000000..a96a4e15e6c
--- /dev/null
+++ b/spec/frontend/issuable_list/components/issuable_item_spec.js
@@ -0,0 +1,185 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink, GlLabel } from '@gitlab/ui';
+
+import IssuableItem from '~/issuable_list/components/issuable_item.vue';
+
+import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data';
+
+const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable } = {}) =>
+ shallowMount(IssuableItem, {
+ propsData: {
+ issuableSymbol,
+ issuable,
+ },
+ });
+
+describe('IssuableItem', () => {
+ const mockLabels = mockIssuable.labels.nodes;
+ const mockAuthor = mockIssuable.author;
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ describe('author', () => {
+ it('returns `issuable.author` reference', () => {
+ expect(wrapper.vm.author).toEqual(mockIssuable.author);
+ });
+ });
+
+ describe('authorId', () => {
+ it.each`
+ authorId | returnValue
+ ${1} | ${1}
+ ${'1'} | ${1}
+ ${'gid://gitlab/User/1'} | ${'1'}
+ ${'foo'} | ${''}
+ `(
+ 'returns $returnValue when value of `issuable.author.id` is $authorId',
+ async ({ authorId, returnValue }) => {
+ wrapper.setProps({
+ issuable: {
+ ...mockIssuable,
+ author: {
+ ...mockAuthor,
+ id: authorId,
+ },
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.authorId).toBe(returnValue);
+ },
+ );
+ });
+
+ describe('labels', () => {
+ it('returns `issuable.labels.nodes` reference when it is available', () => {
+ expect(wrapper.vm.labels).toEqual(mockLabels);
+ });
+
+ it('returns `issuable.labels` reference when it is available', async () => {
+ wrapper.setProps({
+ issuable: {
+ ...mockIssuable,
+ labels: mockLabels,
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.labels).toEqual(mockLabels);
+ });
+
+ it('returns empty array when none of `issuable.labels.nodes` or `issuable.labels` are available', async () => {
+ wrapper.setProps({
+ issuable: {
+ ...mockIssuable,
+ labels: null,
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.labels).toEqual([]);
+ });
+ });
+
+ describe('createdAt', () => {
+ it('returns string containing timeago string based on `issuable.createdAt`', () => {
+ expect(wrapper.vm.createdAt).toContain('created');
+ expect(wrapper.vm.createdAt).toContain('ago');
+ });
+ });
+
+ describe('updatedAt', () => {
+ it('returns string containing timeago string based on `issuable.updatedAt`', () => {
+ expect(wrapper.vm.updatedAt).toContain('updated');
+ expect(wrapper.vm.updatedAt).toContain('ago');
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('scopedLabel', () => {
+ it.each`
+ label | labelType | returnValue
+ ${mockRegularLabel} | ${'regular'} | ${false}
+ ${mockScopedLabel} | ${'scoped'} | ${true}
+ `(
+ 'return $returnValue when provided label param is a $labelType label',
+ ({ label, returnValue }) => {
+ expect(wrapper.vm.scopedLabel(label)).toBe(returnValue);
+ },
+ );
+ });
+ });
+
+ describe('template', () => {
+ it('renders issuable title', () => {
+ const titleEl = wrapper.find('[data-testid="issuable-title"]');
+
+ expect(titleEl.exists()).toBe(true);
+ expect(titleEl.find(GlLink).attributes('href')).toBe(mockIssuable.webUrl);
+ expect(titleEl.find(GlLink).text()).toBe(mockIssuable.title);
+ });
+
+ it('renders issuable reference', () => {
+ const referenceEl = wrapper.find('[data-testid="issuable-reference"]');
+
+ expect(referenceEl.exists()).toBe(true);
+ expect(referenceEl.text()).toBe(`#${mockIssuable.iid}`);
+ });
+
+ it('renders issuable createdAt info', () => {
+ const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]');
+
+ expect(createdAtEl.exists()).toBe(true);
+ expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm GMT+0000');
+ expect(createdAtEl.text()).toBe(wrapper.vm.createdAt);
+ });
+
+ it('renders issuable author info', () => {
+ const authorEl = wrapper.find('[data-testid="issuable-author"]');
+
+ expect(authorEl.exists()).toBe(true);
+ expect(authorEl.attributes()).toMatchObject({
+ 'data-user-id': wrapper.vm.authorId,
+ 'data-username': mockAuthor.username,
+ 'data-name': mockAuthor.name,
+ 'data-avatar-url': mockAuthor.avatarUrl,
+ href: mockAuthor.webUrl,
+ });
+ expect(authorEl.text()).toBe(mockAuthor.name);
+ });
+
+ it('renders gl-label component for each label present within `issuable` prop', () => {
+ const labelsEl = wrapper.findAll(GlLabel);
+
+ expect(labelsEl.exists()).toBe(true);
+ expect(labelsEl).toHaveLength(mockLabels.length);
+ expect(labelsEl.at(0).props()).toMatchObject({
+ backgroundColor: mockLabels[0].color,
+ title: mockLabels[0].title,
+ description: mockLabels[0].description,
+ scoped: false,
+ size: 'sm',
+ });
+ });
+
+ it('renders issuable updatedAt info', () => {
+ const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]');
+
+ expect(updatedAtEl.exists()).toBe(true);
+ expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am GMT+0000');
+ expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt);
+ });
+ });
+});
diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
new file mode 100644
index 00000000000..34184522b55
--- /dev/null
+++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
@@ -0,0 +1,160 @@
+import { mount } from '@vue/test-utils';
+import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
+
+import IssuableListRoot from '~/issuable_list/components/issuable_list_root.vue';
+import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue';
+import IssuableItem from '~/issuable_list/components/issuable_item.vue';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+
+import { mockIssuableListProps } from '../mock_data';
+
+const createComponent = (propsData = mockIssuableListProps) =>
+ mount(IssuableListRoot, {
+ propsData,
+ slots: {
+ 'nav-actions': `
+ <button class="js-new-issuable">New issuable</button>
+ `,
+ 'empty-state': `
+ <p class="js-issuable-empty-state">Issuable empty state</p>
+ `,
+ },
+ });
+
+describe('IssuableListRoot', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders component container element with class "issuable-list-container"', () => {
+ expect(wrapper.classes()).toContain('issuable-list-container');
+ });
+
+ it('renders issuable-tabs component', () => {
+ const tabsEl = wrapper.find(IssuableTabs);
+
+ expect(tabsEl.exists()).toBe(true);
+ expect(tabsEl.props()).toMatchObject({
+ tabs: wrapper.vm.tabs,
+ tabCounts: wrapper.vm.tabCounts,
+ currentTab: wrapper.vm.currentTab,
+ });
+ });
+
+ it('renders contents for slot "nav-actions" within issuable-tab component', () => {
+ const buttonEl = wrapper.find(IssuableTabs).find('button.js-new-issuable');
+
+ expect(buttonEl.exists()).toBe(true);
+ expect(buttonEl.text()).toBe('New issuable');
+ });
+
+ it('renders filtered-search-bar component', () => {
+ const searchEl = wrapper.find(FilteredSearchBar);
+ const {
+ namespace,
+ recentSearchesStorageKey,
+ searchInputPlaceholder,
+ searchTokens,
+ sortOptions,
+ initialFilterValue,
+ initialSortBy,
+ } = wrapper.vm;
+
+ expect(searchEl.exists()).toBe(true);
+ expect(searchEl.props()).toMatchObject({
+ namespace,
+ recentSearchesStorageKey,
+ searchInputPlaceholder,
+ tokens: searchTokens,
+ sortOptions,
+ initialFilterValue,
+ initialSortBy,
+ });
+ });
+
+ it('renders gl-loading-icon when `issuablesLoading` prop is true', async () => {
+ wrapper.setProps({
+ issuablesLoading: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('renders issuable-item component for each item within `issuables` array', () => {
+ const itemsEl = wrapper.findAll(IssuableItem);
+ const mockIssuable = mockIssuableListProps.issuables[0];
+
+ expect(itemsEl).toHaveLength(mockIssuableListProps.issuables.length);
+ expect(itemsEl.at(0).props()).toMatchObject({
+ issuableSymbol: wrapper.vm.issuableSymbol,
+ issuable: mockIssuable,
+ });
+ });
+
+ it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', async () => {
+ wrapper.setProps({
+ issuables: [],
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('p.js-issuable-empty-state').exists()).toBe(true);
+ expect(wrapper.find('p.js-issuable-empty-state').text()).toBe('Issuable empty state');
+ });
+
+ it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
+ wrapper.setProps({
+ showPaginationControls: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ const paginationEl = wrapper.find(GlPagination);
+ expect(paginationEl.exists()).toBe(true);
+ expect(paginationEl.props()).toMatchObject({
+ perPage: 20,
+ value: 1,
+ prevPage: 0,
+ nextPage: 2,
+ align: 'center',
+ });
+ });
+ });
+
+ describe('events', () => {
+ it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
+ wrapper.find(IssuableTabs).vm.$emit('click');
+
+ expect(wrapper.emitted('click-tab')).toBeTruthy();
+ });
+
+ it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
+ const searchEl = wrapper.find(FilteredSearchBar);
+
+ searchEl.vm.$emit('onFilter');
+ expect(wrapper.emitted('filter')).toBeTruthy();
+ searchEl.vm.$emit('onSort');
+ expect(wrapper.emitted('sort')).toBeTruthy();
+ });
+
+ it('gl-pagination component emits `page-change` event on `input` event', async () => {
+ wrapper.setProps({
+ showPaginationControls: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ wrapper.find(GlPagination).vm.$emit('input');
+ expect(wrapper.emitted('page-change')).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/issuable_list/components/issuable_tabs_spec.js b/spec/frontend/issuable_list/components/issuable_tabs_spec.js
new file mode 100644
index 00000000000..12611400084
--- /dev/null
+++ b/spec/frontend/issuable_list/components/issuable_tabs_spec.js
@@ -0,0 +1,91 @@
+import { mount } from '@vue/test-utils';
+import { GlTab, GlBadge } from '@gitlab/ui';
+
+import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue';
+
+import { mockIssuableListProps } from '../mock_data';
+
+const createComponent = ({
+ tabs = mockIssuableListProps.tabs,
+ tabCounts = mockIssuableListProps.tabCounts,
+ currentTab = mockIssuableListProps.currentTab,
+} = {}) =>
+ mount(IssuableTabs, {
+ propsData: {
+ tabs,
+ tabCounts,
+ currentTab,
+ },
+ slots: {
+ 'nav-actions': `
+ <button class="js-new-issuable">New issuable</button>
+ `,
+ },
+ });
+
+describe('IssuableTabs', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('methods', () => {
+ describe('isTabActive', () => {
+ it.each`
+ tabName | currentTab | returnValue
+ ${'opened'} | ${'opened'} | ${true}
+ ${'opened'} | ${'closed'} | ${false}
+ `(
+ 'returns $returnValue when tab name is "$tabName" is current tab is "$currentTab"',
+ async ({ tabName, currentTab, returnValue }) => {
+ wrapper.setProps({
+ currentTab,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.isTabActive(tabName)).toBe(returnValue);
+ },
+ );
+ });
+ });
+
+ describe('template', () => {
+ it('renders gl-tab for each tab within `tabs` array', () => {
+ const tabsEl = wrapper.findAll(GlTab);
+
+ expect(tabsEl.exists()).toBe(true);
+ expect(tabsEl).toHaveLength(mockIssuableListProps.tabs.length);
+ });
+
+ it('renders gl-badge component within a tab', () => {
+ const badgeEl = wrapper.findAll(GlBadge).at(0);
+
+ expect(badgeEl.exists()).toBe(true);
+ expect(badgeEl.text()).toBe(`${mockIssuableListProps.tabCounts.opened}`);
+ });
+
+ it('renders contents for slot "nav-actions"', () => {
+ const buttonEl = wrapper.find('button.js-new-issuable');
+
+ expect(buttonEl.exists()).toBe(true);
+ expect(buttonEl.text()).toBe('New issuable');
+ });
+ });
+
+ describe('events', () => {
+ it('gl-tab component emits `click` event on `click` event', () => {
+ const tabEl = wrapper.findAll(GlTab).at(0);
+
+ tabEl.vm.$emit('click', 'opened');
+
+ expect(wrapper.emitted('click')).toBeTruthy();
+ expect(wrapper.emitted('click')[0]).toEqual(['opened']);
+ });
+ });
+});
diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js
new file mode 100644
index 00000000000..f6f914a595d
--- /dev/null
+++ b/spec/frontend/issuable_list/mock_data.js
@@ -0,0 +1,135 @@
+import {
+ mockAuthorToken,
+ mockLabelToken,
+ mockSortOptions,
+} from 'jest/vue_shared/components/filtered_search_bar/mock_data';
+
+export const mockAuthor = {
+ id: 'gid://gitlab/User/1',
+ avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://0.0.0.0:3000/root',
+};
+
+export const mockRegularLabel = {
+ id: 'gid://gitlab/GroupLabel/2048',
+ title: 'Documentation Update',
+ description: null,
+ color: '#F0AD4E',
+ textColor: '#FFFFFF',
+};
+
+export const mockScopedLabel = {
+ id: 'gid://gitlab/ProjectLabel/2049',
+ title: 'status::confirmed',
+ description: null,
+ color: '#D9534F',
+ textColor: '#FFFFFF',
+};
+
+export const mockLabels = [mockRegularLabel, mockScopedLabel];
+
+export const mockIssuable = {
+ iid: '30',
+ title: 'Dismiss Cipher with no integrity',
+ description: null,
+ createdAt: '2020-06-29T13:52:56Z',
+ updatedAt: '2020-09-10T11:41:13Z',
+ webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/30',
+ author: mockAuthor,
+ labels: {
+ nodes: mockLabels,
+ },
+};
+
+export const mockIssuables = [
+ mockIssuable,
+ {
+ iid: '28',
+ title: 'Dismiss Cipher with no integrity',
+ description: null,
+ createdAt: '2020-06-29T13:52:56Z',
+ updatedAt: '2020-06-29T13:52:56Z',
+ webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/28',
+ author: mockAuthor,
+ labels: {
+ nodes: [],
+ },
+ },
+ {
+ iid: '7',
+ title: 'Temporibus in veritatis labore explicabo velit molestiae sed.',
+ description: 'Quo consequatur rem aliquid laborum quibusdam molestiae saepe.',
+ createdAt: '2020-06-25T13:50:14Z',
+ updatedAt: '2020-08-25T06:09:27Z',
+ webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/7',
+ author: mockAuthor,
+ labels: {
+ nodes: mockLabels,
+ },
+ },
+ {
+ iid: '17',
+ title: 'Vel voluptatem quaerat est hic incidunt qui ut aliquid sit exercitationem.',
+ description: 'Incidunt accusamus perspiciatis aut excepturi.',
+ createdAt: '2020-06-19T13:51:36Z',
+ updatedAt: '2020-08-11T13:36:35Z',
+ webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/17',
+ author: mockAuthor,
+ labels: {
+ nodes: [],
+ },
+ },
+ {
+ iid: '16',
+ title: 'Vero qui quo labore libero omnis quisquam et cumque.',
+ description: 'Ipsa ipsum magni nostrum alias aut exercitationem.',
+ createdAt: '2020-06-19T13:51:36Z',
+ updatedAt: '2020-06-19T13:51:36Z',
+ webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/16',
+ author: mockAuthor,
+ labels: {
+ nodes: [],
+ },
+ },
+];
+
+export const mockTabs = [
+ {
+ id: 'state-opened',
+ name: 'opened',
+ title: 'Open',
+ titleTooltip: 'Filter by issuables that are currently opened.',
+ },
+ {
+ id: 'state-archived',
+ name: 'closed',
+ title: 'Closed',
+ titleTooltip: 'Filter by issuables that are currently archived.',
+ },
+ {
+ id: 'state-all',
+ name: 'all',
+ title: 'All',
+ titleTooltip: 'Show all issuables.',
+ },
+];
+
+export const mockTabCounts = {
+ opened: 5,
+ closed: 0,
+ all: 5,
+};
+
+export const mockIssuableListProps = {
+ namespace: 'gitlab-org/gitlab-test',
+ recentSearchesStorageKey: 'issues',
+ searchInputPlaceholder: 'Search issues',
+ searchTokens: [mockAuthorToken, mockLabelToken],
+ sortOptions: mockSortOptions,
+ issuables: mockIssuables,
+ tabs: mockTabs,
+ tabCounts: mockTabCounts,
+ currentTab: 'opened',
+};
diff --git a/spec/frontend/issuable_suggestions/components/app_spec.js b/spec/frontend/issuable_suggestions/components/app_spec.js
index d51c89807be..0cb5b9c90ba 100644
--- a/spec/frontend/issuable_suggestions/components/app_spec.js
+++ b/spec/frontend/issuable_suggestions/components/app_spec.js
@@ -41,7 +41,7 @@ describe('Issuable suggestions app component', () => {
wrapper.setData(data);
return wrapper.vm.$nextTick(() => {
- expect(wrapper.isEmpty()).toBe(false);
+ expect(wrapper.findAll('li').length).toBe(data.issues.length);
});
});
@@ -89,8 +89,8 @@ describe('Issuable suggestions app component', () => {
wrapper
.findAll('li')
.at(0)
- .is('.gl-mb-3'),
- ).toBe(true);
+ .classes(),
+ ).toContain('gl-mb-3');
});
});
@@ -102,8 +102,8 @@ describe('Issuable suggestions app component', () => {
wrapper
.findAll('li')
.at(1)
- .is('.gl-mb-3'),
- ).toBe(false);
+ .classes(),
+ ).not.toContain('gl-mb-3');
});
});
});
diff --git a/spec/frontend/issuable_suggestions/components/item_spec.js b/spec/frontend/issuable_suggestions/components/item_spec.js
index ad37ccd2ca5..9912e77d5fe 100644
--- a/spec/frontend/issuable_suggestions/components/item_spec.js
+++ b/spec/frontend/issuable_suggestions/components/item_spec.js
@@ -1,7 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import { GlTooltip, GlLink } from '@gitlab/ui';
+import { GlTooltip, GlLink, GlIcon } from '@gitlab/ui';
import { TEST_HOST } from 'jest/helpers/test_constants';
-import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import Suggestion from '~/issuable_suggestions/components/item.vue';
import mockData from '../mock_data';
@@ -48,7 +47,7 @@ describe('Issuable suggestions suggestion component', () => {
it('renders icon', () => {
createComponent();
- const icon = vm.find(Icon);
+ const icon = vm.find(GlIcon);
expect(icon.props('name')).toBe('issue-open-m');
});
@@ -71,7 +70,7 @@ describe('Issuable suggestions suggestion component', () => {
state: 'closed',
});
- const icon = vm.find(Icon);
+ const icon = vm.find(GlIcon);
expect(icon.props('name')).toBe('issue-close');
});
@@ -112,7 +111,7 @@ describe('Issuable suggestions suggestion component', () => {
const count = vm.findAll('.suggestion-counts span').at(0);
expect(count.text()).toContain('1');
- expect(count.find(Icon).props('name')).toBe('thumb-up');
+ expect(count.find(GlIcon).props('name')).toBe('thumb-up');
});
it('renders notes count', () => {
@@ -121,7 +120,7 @@ describe('Issuable suggestions suggestion component', () => {
const count = vm.findAll('.suggestion-counts span').at(1);
expect(count.text()).toContain('2');
- expect(count.find(Icon).props('name')).toBe('comment');
+ expect(count.find(GlIcon).props('name')).toBe('comment');
});
});
@@ -131,7 +130,7 @@ describe('Issuable suggestions suggestion component', () => {
confidential: true,
});
- const icon = vm.find(Icon);
+ const icon = vm.find(GlIcon);
expect(icon.props('name')).toBe('eye-slash');
expect(icon.attributes('title')).toBe('Confidential');
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js
index f76f42cb9ae..f4095d4de96 100644
--- a/spec/frontend/issue_show/components/app_spec.js
+++ b/spec/frontend/issue_show/components/app_spec.js
@@ -1,14 +1,22 @@
import { GlIntersectionObserver } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
-import { TEST_HOST } from 'helpers/test_constants';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
-import { initialRequest, secondRequest } from '../mock_data';
+import {
+ appProps,
+ initialRequest,
+ publishedIncidentUrl,
+ secondRequest,
+ zoomMeetingUrl,
+} from '../mock_data';
+import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
+import DescriptionComponent from '~/issue_show/components/description.vue';
+import PinnedLinks from '~/issue_show/components/pinned_links.vue';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
@@ -19,9 +27,6 @@ jest.mock('~/issue_show/event_hub');
const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
-const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811';
-const publishedIncidentUrl = 'https://status.com/';
-
describe('Issuable output', () => {
useMockIntersectionObserver();
@@ -31,6 +36,21 @@ describe('Issuable output', () => {
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
+ const mountComponent = (props = {}, options = {}) => {
+ wrapper = mount(IssuableApp, {
+ propsData: { ...appProps, ...props },
+ provide: {
+ fullPath: 'gitlab-org/incidents',
+ iid: '19',
+ },
+ stubs: {
+ HighlightBar: true,
+ IncidentTabs: true,
+ },
+ ...options,
+ });
+ };
+
beforeEach(() => {
setFixtures(`
<div>
@@ -57,28 +77,9 @@ describe('Issuable output', () => {
return res;
});
- wrapper = mount(IssuableApp, {
- propsData: {
- canUpdate: true,
- canDestroy: true,
- endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
- updateEndpoint: TEST_HOST,
- issuableRef: '#1',
- issuableStatus: 'opened',
- initialTitleHtml: '',
- initialTitleText: '',
- initialDescriptionHtml: 'test',
- initialDescriptionText: 'test',
- lockVersion: 1,
- markdownPreviewPath: '/',
- markdownDocsPath: '/',
- projectNamespace: '/',
- projectPath: '/',
- issuableTemplateNamesPath: '/issuable-templates-path',
- zoomMeetingUrl,
- publishedIncidentUrl,
- },
- });
+ mountComponent();
+
+ jest.advanceTimersByTime(2);
});
afterEach(() => {
@@ -134,7 +135,7 @@ describe('Issuable output', () => {
wrapper.vm.showForm = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.contains('.markdown-selector')).toBe(true);
+ expect(wrapper.find('.markdown-selector').exists()).toBe(true);
});
});
@@ -143,7 +144,7 @@ describe('Issuable output', () => {
wrapper.setProps({ canUpdate: false });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.contains('.markdown-selector')).toBe(false);
+ expect(wrapper.find('.markdown-selector').exists()).toBe(false);
});
});
@@ -403,7 +404,7 @@ describe('Issuable output', () => {
.then(() => {
expect(wrapper.vm.formState.lockedWarningVisible).toEqual(true);
expect(wrapper.vm.formState.lock_version).toEqual(1);
- expect(wrapper.contains('.alert')).toBe(true);
+ expect(wrapper.find('.alert').exists()).toBe(true);
});
});
});
@@ -441,14 +442,14 @@ describe('Issuable output', () => {
describe('show inline edit button', () => {
it('should not render by default', () => {
- expect(wrapper.contains('.btn-edit')).toBe(true);
+ expect(wrapper.find('.btn-edit').exists()).toBe(true);
});
it('should render if showInlineEditButton', () => {
wrapper.setProps({ showInlineEditButton: true });
return wrapper.vm.$nextTick(() => {
- expect(wrapper.contains('.btn-edit')).toBe(true);
+ expect(wrapper.find('.btn-edit').exists()).toBe(true);
});
});
});
@@ -531,7 +532,7 @@ describe('Issuable output', () => {
describe('sticky header', () => {
describe('when title is in view', () => {
it('is not shown', () => {
- expect(wrapper.contains('.issue-sticky-header')).toBe(false);
+ expect(wrapper.find('.issue-sticky-header').exists()).toBe(false);
});
});
@@ -562,4 +563,59 @@ describe('Issuable output', () => {
});
});
});
+
+ describe('Composable description component', () => {
+ const findIncidentTabs = () => wrapper.find(IncidentTabs);
+ const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
+ const findPinnedLinks = () => wrapper.find(PinnedLinks);
+ const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
+
+ describe('when using description component', () => {
+ it('renders the description component', () => {
+ expect(findDescriptionComponent().exists()).toBe(true);
+ });
+
+ it('does not render incident tabs', () => {
+ expect(findIncidentTabs().exists()).toBe(false);
+ });
+
+ it('adds a border below the header', () => {
+ expect(findPinnedLinks().attributes('class')).toContain(borderClass);
+ });
+ });
+
+ describe('when using incident tabs description wrapper', () => {
+ beforeEach(() => {
+ mountComponent(
+ {
+ descriptionComponent: IncidentTabs,
+ showTitleBorder: false,
+ },
+ {
+ mocks: {
+ $apollo: {
+ queries: {
+ alert: {
+ loading: false,
+ },
+ },
+ },
+ },
+ },
+ );
+ });
+
+ it('renders the description component', () => {
+ expect(findDescriptionComponent().exists()).toBe(true);
+ });
+
+ it('renders incident tabs', () => {
+ expect(findIncidentTabs().exists()).toBe(true);
+ });
+
+ it('does not add a border below the header', () => {
+ expect(findPinnedLinks().attributes('class')).not.toContain(borderClass);
+ });
+ });
+ });
});
diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js
index 0053475dd13..bc7511225a0 100644
--- a/spec/frontend/issue_show/components/description_spec.js
+++ b/spec/frontend/issue_show/components/description_spec.js
@@ -5,20 +5,13 @@ import mountComponent from 'helpers/vue_mount_component_helper';
import { TEST_HOST } from 'helpers/test_constants';
import Description from '~/issue_show/components/description.vue';
import TaskList from '~/task_list';
+import { descriptionProps as props } from '../mock_data';
jest.mock('~/task_list');
describe('Description component', () => {
let vm;
let DescriptionComponent;
- const props = {
- canUpdate: true,
- descriptionHtml: 'test',
- descriptionText: 'test',
- updatedAt: new Date().toString(),
- taskStatus: '',
- updateUrl: TEST_HOST,
- };
beforeEach(() => {
DescriptionComponent = Vue.extend(Description);
@@ -43,12 +36,27 @@ describe('Description component', () => {
$('.issuable-meta .flash-container').remove();
});
- it('animates description changes', () => {
+ it('doesnt animate first description changes', () => {
vm.descriptionHtml = 'changed';
+ return vm.$nextTick().then(() => {
+ expect(
+ vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'),
+ ).toBeFalsy();
+ jest.runAllTimers();
+ return vm.$nextTick();
+ });
+ });
+
+ it('animates description changes on live update', () => {
+ vm.descriptionHtml = 'changed';
return vm
.$nextTick()
.then(() => {
+ vm.descriptionHtml = 'changed second time';
+ return vm.$nextTick();
+ })
+ .then(() => {
expect(
vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'),
).toBeTruthy();
diff --git a/spec/frontend/issue_show/components/edit_actions_spec.js b/spec/frontend/issue_show/components/edit_actions_spec.js
index b0c1894058e..79a2bcd5eab 100644
--- a/spec/frontend/issue_show/components/edit_actions_spec.js
+++ b/spec/frontend/issue_show/components/edit_actions_spec.js
@@ -70,16 +70,6 @@ describe('Edit Actions components', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable');
});
- it('shows loading icon after clicking save button', done => {
- vm.$el.querySelector('.btn-success').click();
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.btn-success .fa')).not.toBeNull();
-
- done();
- });
- });
-
it('disabled button after clicking save button', done => {
vm.$el.querySelector('.btn-success').click();
@@ -107,17 +97,6 @@ describe('Edit Actions components', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true });
});
- it('shows loading icon after clicking delete button', done => {
- jest.spyOn(window, 'confirm').mockReturnValue(true);
- vm.$el.querySelector('.btn-danger').click();
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.btn-danger .fa')).not.toBeNull();
-
- done();
- });
- });
-
it('does no actions when confirm is false', done => {
jest.spyOn(window, 'confirm').mockReturnValue(false);
vm.$el.querySelector('.btn-danger').click();
diff --git a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js
new file mode 100644
index 00000000000..8d50df5e406
--- /dev/null
+++ b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js
@@ -0,0 +1,56 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink } from '@gitlab/ui';
+import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue';
+import { formatDate } from '~/lib/utils/datetime_utility';
+
+jest.mock('~/lib/utils/datetime_utility');
+
+describe('Highlight Bar', () => {
+ let wrapper;
+
+ const alert = {
+ startedAt: '2020-05-29T10:39:22Z',
+ detailsUrl: 'http://127.0.0.1:3000/root/unique-alerts/-/alert_management/1/details',
+ eventCount: 1,
+ title: 'Alert 1',
+ };
+
+ const mountComponent = () => {
+ wrapper = shallowMount(HighlightBar, {
+ propsData: {
+ alert,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findLink = () => wrapper.find(GlLink);
+
+ it('renders a link to the alert page', () => {
+ expect(findLink().exists()).toBe(true);
+ expect(findLink().attributes('href')).toBe(alert.detailsUrl);
+ expect(findLink().text()).toContain(alert.title);
+ });
+
+ it('renders formatted start time of the alert', () => {
+ const formattedDate = '2020-05-29 UTC';
+ formatDate.mockReturnValueOnce(formattedDate);
+ mountComponent();
+ expect(formatDate).toHaveBeenCalledWith(alert.startedAt, 'yyyy-mm-dd Z');
+ expect(wrapper.text()).toContain(formattedDate);
+ });
+
+ it('renders a number of alert events', () => {
+ expect(wrapper.text()).toContain(alert.eventCount);
+ });
+});
diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
new file mode 100644
index 00000000000..a51b497cd79
--- /dev/null
+++ b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
@@ -0,0 +1,101 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlTab } from '@gitlab/ui';
+import INVALID_URL from '~/lib/utils/invalid_url';
+import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
+import { descriptionProps } from '../../mock_data';
+import DescriptionComponent from '~/issue_show/components/description.vue';
+import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue';
+import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+
+const mockAlert = {
+ __typename: 'AlertManagementAlert',
+ detailsUrl: INVALID_URL,
+ iid: '1',
+};
+
+describe('Incident Tabs component', () => {
+ let wrapper;
+
+ const mountComponent = (data = {}) => {
+ wrapper = shallowMount(IncidentTabs, {
+ propsData: {
+ ...descriptionProps,
+ },
+ stubs: {
+ DescriptionComponent: true,
+ },
+ provide: {
+ fullPath: '',
+ iid: '',
+ },
+ data() {
+ return { alert: mockAlert, ...data };
+ },
+ mocks: {
+ $apollo: {
+ queries: {
+ alert: {
+ loading: true,
+ },
+ },
+ },
+ },
+ });
+ };
+
+ const findTabs = () => wrapper.findAll(GlTab);
+ const findSummaryTab = () => findTabs().at(0);
+ const findAlertDetailsTab = () => findTabs().at(1);
+ const findAlertDetailsComponent = () => wrapper.find(AlertDetailsTable);
+ const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
+ const findHighlightBarComponent = () => wrapper.find(HighlightBar);
+
+ describe('empty state', () => {
+ beforeEach(() => {
+ mountComponent({ alert: null });
+ });
+
+ it('does not show the alert details tab', () => {
+ expect(findAlertDetailsComponent().exists()).toBe(false);
+ expect(findHighlightBarComponent().exists()).toBe(false);
+ });
+ });
+
+ describe('with an alert present', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('renders the summary tab', () => {
+ expect(findSummaryTab().exists()).toBe(true);
+ expect(findSummaryTab().attributes('title')).toBe('Summary');
+ });
+
+ it('renders the alert details tab', () => {
+ expect(findAlertDetailsTab().exists()).toBe(true);
+ expect(findAlertDetailsTab().attributes('title')).toBe('Alert details');
+ });
+
+ it('renders the alert details table with the correct props', () => {
+ const alert = { iid: mockAlert.iid };
+
+ expect(findAlertDetailsComponent().props('alert')).toEqual(alert);
+ expect(findAlertDetailsComponent().props('loading')).toBe(true);
+ });
+
+ it('renders the description component with highlight bar', () => {
+ expect(findDescriptionComponent().exists()).toBe(true);
+ expect(findHighlightBarComponent().exists()).toBe(true);
+ });
+
+ it('renders the highlight bar component with the correct props', () => {
+ const alert = { detailsUrl: mockAlert.detailsUrl };
+
+ expect(findHighlightBarComponent().props('alert')).toMatchObject(alert);
+ });
+
+ it('passes all props to the description component', () => {
+ expect(findDescriptionComponent().props()).toMatchObject(descriptionProps);
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/helpers.js b/spec/frontend/issue_show/helpers.js
index 5d2ced98ae4..7ca6a22929d 100644
--- a/spec/frontend/issue_show/helpers.js
+++ b/spec/frontend/issue_show/helpers.js
@@ -1,4 +1,3 @@
-// eslint-disable-next-line import/prefer-default-export
export const keyboardDownEvent = (code, metaKey = false, ctrlKey = false) => {
const e = new CustomEvent('keydown');
diff --git a/spec/frontend/issue_show/index_spec.js b/spec/frontend/issue_show/index_spec.js
deleted file mode 100644
index e80d1b83c11..00000000000
--- a/spec/frontend/issue_show/index_spec.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import initIssueableApp from '~/issue_show';
-
-describe('Issue show index', () => {
- describe('initIssueableApp', () => {
- it('should initialize app with no potential XSS attack', () => {
- const d = document.createElement('div');
- d.id = 'js-issuable-app-initial-data';
- d.innerHTML = JSON.stringify({
- initialDescriptionHtml: '&lt;img src=x onerror=alert(1)&gt;',
- });
- document.body.appendChild(d);
-
- const alertSpy = jest.spyOn(window, 'alert');
- initIssueableApp();
-
- expect(alertSpy).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js
new file mode 100644
index 00000000000..befb670c6cd
--- /dev/null
+++ b/spec/frontend/issue_show/issue_spec.js
@@ -0,0 +1,45 @@
+import MockAdapter from 'axios-mock-adapter';
+import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import initIssuableApp from '~/issue_show/issue';
+import * as parseData from '~/issue_show/utils/parse_data';
+import { appProps } from './mock_data';
+
+const mock = new MockAdapter(axios);
+mock.onGet().reply(200);
+
+useMockIntersectionObserver();
+
+jest.mock('~/lib/utils/poll');
+
+const setupHTML = initialData => {
+ document.body.innerHTML = `
+ <div id="js-issuable-app"></div>
+ <script id="js-issuable-app-initial-data" type="application/json">
+ ${JSON.stringify(initialData)}
+ </script>
+ `;
+};
+
+describe('Issue show index', () => {
+ describe('initIssueableApp', () => {
+ it('should initialize app with no potential XSS attack', async () => {
+ const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
+ const parseDataSpy = jest.spyOn(parseData, 'parseIssuableData');
+
+ setupHTML({
+ ...appProps,
+ initialDescriptionHtml: '<svg onload=window.alert(1)>',
+ });
+
+ const issuableData = parseData.parseIssuableData();
+ initIssuableApp(issuableData);
+
+ await waitForPromises();
+
+ expect(parseDataSpy).toHaveBeenCalled();
+ expect(alertSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/mock_data.js b/spec/frontend/issue_show/mock_data.js
index ff01a004186..5a31a550088 100644
--- a/spec/frontend/issue_show/mock_data.js
+++ b/spec/frontend/issue_show/mock_data.js
@@ -1,3 +1,5 @@
+import { TEST_HOST } from 'helpers/test_constants';
+
export const initialRequest = {
title: '<p>this is a title</p>',
title_text: 'this is a title',
@@ -21,3 +23,36 @@ export const secondRequest = {
updated_by_path: '/other_user',
lock_version: 2,
};
+
+export const descriptionProps = {
+ canUpdate: true,
+ descriptionHtml: 'test',
+ descriptionText: 'test',
+ taskStatus: '',
+ updateUrl: TEST_HOST,
+};
+
+export const publishedIncidentUrl = 'https://status.com/';
+
+export const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811';
+
+export const appProps = {
+ canUpdate: true,
+ canDestroy: true,
+ endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
+ updateEndpoint: TEST_HOST,
+ issuableRef: '#1',
+ issuableStatus: 'opened',
+ initialTitleHtml: '',
+ initialTitleText: '',
+ initialDescriptionHtml: 'test',
+ initialDescriptionText: 'test',
+ lockVersion: 1,
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
+ projectNamespace: '/',
+ projectPath: '/',
+ issuableTemplateNamesPath: '/issuable-templates-path',
+ zoomMeetingUrl,
+ publishedIncidentUrl,
+};
diff --git a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap b/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap
index c327b7de827..c327b7de827 100644
--- a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap
+++ b/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap
diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js
index 6ede46a602a..c20684cc385 100644
--- a/spec/frontend/issuables_list/components/issuable_spec.js
+++ b/spec/frontend/issues_list/components/issuable_spec.js
@@ -5,7 +5,7 @@ import { trimText } from 'helpers/text_helper';
import initUserPopovers from '~/user_popovers';
import { formatDate } from '~/lib/utils/datetime_utility';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import Issuable from '~/issuables_list/components/issuable.vue';
+import Issuable from '~/issues_list/components/issuable.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data';
import { isScopedLabel } from '~/lib/utils/common_utils';
@@ -52,7 +52,6 @@ describe('Issuable component', () => {
},
stubs: {
'gl-sprintf': GlSprintf,
- 'gl-link': '<a><slot></slot></a>',
},
});
};
@@ -98,7 +97,7 @@ describe('Issuable component', () => {
const findUnscopedLabels = () => findLabels().filter(w => !isScopedLabel({ title: w.text() }));
const findIssuableTitle = () => wrapper.find('[data-testid="issuable-title"]');
const findIssuableStatus = () => wrapper.find('[data-testid="issuable-status"]');
- const containsJiraLogo = () => wrapper.contains('[data-testid="jira-logo"]');
+ const containsJiraLogo = () => wrapper.find('[data-testid="jira-logo"]').exists();
const findHealthStatus = () => wrapper.find('.health-status');
describe('when mounted', () => {
diff --git a/spec/frontend/issuables_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js
index 65b87ddf6a6..1f80b4fc54a 100644
--- a/spec/frontend/issuables_list/components/issuables_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js
@@ -1,18 +1,22 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
-import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui';
+import {
+ GlEmptyState,
+ GlPagination,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+} from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'helpers/test_constants';
import { deprecatedCreateFlash as flash } from '~/flash';
-import IssuablesListApp from '~/issuables_list/components/issuables_list_app.vue';
-import Issuable from '~/issuables_list/components/issuable.vue';
+import IssuablesListApp from '~/issues_list/components/issuables_list_app.vue';
+import Issuable from '~/issues_list/components/issuable.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import issueablesEventBus from '~/issuables_list/eventhub';
-import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issuables_list/constants';
+import issueablesEventBus from '~/issues_list/eventhub';
+import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issues_list/constants';
jest.mock('~/flash');
-jest.mock('~/issuables_list/eventhub');
+jest.mock('~/issues_list/eventhub');
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
scrollToElement: () => {},
@@ -169,7 +173,7 @@ describe('Issuables list component', () => {
it('does not display empty state', () => {
expect(wrapper.vm.issuables.length).toBeGreaterThan(0);
expect(wrapper.vm.emptyState).toEqual({});
- expect(wrapper.contains(GlEmptyState)).toBe(false);
+ expect(wrapper.find(GlEmptyState).exists()).toBe(false);
});
it('sets the proper page and total items', () => {
diff --git a/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js b/spec/frontend/issues_list/components/jira_issues_list_root_spec.js
index aee49076b5d..eecb092a330 100644
--- a/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js
+++ b/spec/frontend/issues_list/components/jira_issues_list_root_spec.js
@@ -1,9 +1,9 @@
import { GlAlert, GlLabel } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
-import IssuableListRootApp from '~/issuables_list/components/issuable_list_root_app.vue';
+import JiraIssuesListRoot from '~/issues_list/components/jira_issues_list_root.vue';
-describe('IssuableListRootApp', () => {
+describe('JiraIssuesListRoot', () => {
const issuesPath = 'gitlab-org/gitlab-test/-/issues';
const label = {
color: '#333',
@@ -19,7 +19,7 @@ describe('IssuableListRootApp', () => {
shouldShowFinishedAlert = false,
shouldShowInProgressAlert = false,
} = {}) =>
- shallowMount(IssuableListRootApp, {
+ shallowMount(JiraIssuesListRoot, {
propsData: {
canEdit: true,
isJiraConfigured: true,
@@ -47,7 +47,7 @@ describe('IssuableListRootApp', () => {
it('does not show an alert', () => {
wrapper = mountComponent();
- expect(wrapper.contains(GlAlert)).toBe(false);
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
});
});
@@ -103,12 +103,12 @@ describe('IssuableListRootApp', () => {
shouldShowInProgressAlert: true,
});
- expect(wrapper.contains(GlAlert)).toBe(true);
+ expect(wrapper.find(GlAlert).exists()).toBe(true);
findAlert().vm.$emit('dismiss');
return Vue.nextTick(() => {
- expect(wrapper.contains(GlAlert)).toBe(false);
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/issuables_list/issuable_list_test_data.js b/spec/frontend/issues_list/issuable_list_test_data.js
index 313aa15bd31..313aa15bd31 100644
--- a/spec/frontend/issuables_list/issuable_list_test_data.js
+++ b/spec/frontend/issues_list/issuable_list_test_data.js
diff --git a/spec/frontend/issues_list/service_desk_helper_spec.js b/spec/frontend/issues_list/service_desk_helper_spec.js
new file mode 100644
index 00000000000..16aee853341
--- /dev/null
+++ b/spec/frontend/issues_list/service_desk_helper_spec.js
@@ -0,0 +1,28 @@
+import { emptyStateHelper, generateMessages } from '~/issues_list/service_desk_helper';
+
+describe('service desk helper', () => {
+ const emptyStateMessages = generateMessages({});
+
+ // Note: isServiceDeskEnabled must not be true when isServiceDeskSupported is false (it's an invalid case).
+ describe.each`
+ isServiceDeskSupported | isServiceDeskEnabled | canEditProjectSettings | expectedMessage
+ ${true} | ${true} | ${true} | ${'serviceDeskEnabledAndCanEditProjectSettings'}
+ ${true} | ${true} | ${false} | ${'serviceDeskEnabledAndCannotEditProjectSettings'}
+ ${true} | ${false} | ${true} | ${'serviceDeskDisabledAndCanEditProjectSettings'}
+ ${true} | ${false} | ${false} | ${'serviceDeskDisabledAndCannotEditProjectSettings'}
+ ${false} | ${false} | ${true} | ${'serviceDeskIsNotSupported'}
+ ${false} | ${false} | ${false} | ${'serviceDeskIsNotEnabled'}
+ `(
+ 'isServiceDeskSupported = $isServiceDeskSupported, isServiceDeskEnabled = $isServiceDeskEnabled, canEditProjectSettings = $canEditProjectSettings',
+ ({ isServiceDeskSupported, isServiceDeskEnabled, canEditProjectSettings, expectedMessage }) => {
+ it(`displays ${expectedMessage} message`, () => {
+ const emptyStateMeta = {
+ isServiceDeskEnabled,
+ isServiceDeskSupported,
+ canEditProjectSettings,
+ };
+ expect(emptyStateHelper(emptyStateMeta)).toEqual(emptyStateMessages[expectedMessage]);
+ });
+ },
+ );
+});
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 975c31bb59c..eede5426f42 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
@@ -114,7 +114,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
<!---->
<div
- class="gl-search-box-by-type m-2"
+ class="gl-search-box-by-type gl-m-3"
>
<svg
class="gl-search-box-by-type-search-icon gl-icon s16"
@@ -225,7 +225,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
<!---->
<div
- class="gl-search-box-by-type m-2"
+ class="gl-search-box-by-type gl-m-3"
>
<svg
class="gl-search-box-by-type-search-icon gl-icon s16"
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 6ef28a71f48..d184c054b8a 100644
--- a/spec/frontend/jira_import/components/jira_import_form_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_form_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlButton, GlNewDropdown, GlFormSelect, GlLabel, GlTable } from '@gitlab/ui';
+import { GlAlert, GlButton, GlDropdown, GlFormSelect, GlLabel, GlTable } from '@gitlab/ui';
import { getByRole } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
@@ -35,7 +35,7 @@ describe('JiraImportForm', () => {
const getTable = () => wrapper.find(GlTable);
- const getUserDropdown = () => getTable().find(GlNewDropdown);
+ const getUserDropdown = () => getTable().find(GlDropdown);
const getHeader = name => getByRole(wrapper.element, 'columnheader', { name });
@@ -100,7 +100,7 @@ describe('JiraImportForm', () => {
it('is shown', () => {
wrapper = mountComponent();
- expect(wrapper.contains(GlFormSelect)).toBe(true);
+ expect(wrapper.find(GlFormSelect).exists()).toBe(true);
});
it('contains a list of Jira projects to select from', () => {
diff --git a/spec/frontend/jira_import/utils/jira_import_utils_spec.js b/spec/frontend/jira_import/utils/jira_import_utils_spec.js
index 8ae1fc3535a..0992c9e8d16 100644
--- a/spec/frontend/jira_import/utils/jira_import_utils_spec.js
+++ b/spec/frontend/jira_import/utils/jira_import_utils_spec.js
@@ -8,7 +8,7 @@ import {
setFinishedAlertHideMap,
shouldShowFinishedAlert,
} from '~/jira_import/utils/jira_import_utils';
-import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issuables_list/constants';
+import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issues_list/constants';
useLocalStorageSpy();
diff --git a/spec/frontend/jobs/components/artifacts_block_spec.js b/spec/frontend/jobs/components/artifacts_block_spec.js
index 11bd645916e..a709a59cadd 100644
--- a/spec/frontend/jobs/components/artifacts_block_spec.js
+++ b/spec/frontend/jobs/components/artifacts_block_spec.js
@@ -8,7 +8,10 @@ describe('Artifacts block', () => {
const createWrapper = propsData =>
mount(ArtifactsBlock, {
- propsData,
+ propsData: {
+ helpUrl: 'help-url',
+ ...propsData,
+ },
});
const findArtifactRemoveElt = () => wrapper.find('[data-testid="artifacts-remove-timeline"]');
@@ -68,6 +71,12 @@ describe('Artifacts block', () => {
expect(trimText(findArtifactRemoveElt().text())).toBe(
`The artifacts were removed ${formattedDate}`,
);
+
+ expect(
+ findArtifactRemoveElt()
+ .find('[data-testid="artifact-expired-help-link"]')
+ .attributes('href'),
+ ).toBe('help-url');
});
it('does not show the keep button', () => {
@@ -94,6 +103,12 @@ describe('Artifacts block', () => {
expect(trimText(findArtifactRemoveElt().text())).toBe(
`The artifacts will be removed ${formattedDate}`,
);
+
+ expect(
+ findArtifactRemoveElt()
+ .find('[data-testid="artifact-expired-help-link"]')
+ .attributes('href'),
+ ).toBe('help-url');
});
it('renders the keep button', () => {
diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js
index e9ecafcd4c3..94653d4d4c7 100644
--- a/spec/frontend/jobs/components/job_app_spec.js
+++ b/spec/frontend/jobs/components/job_app_spec.js
@@ -33,6 +33,7 @@ describe('Job App', () => {
};
const props = {
+ artifactHelpUrl: 'help/artifact',
runnerHelpUrl: 'help/runner',
deploymentHelpUrl: 'help/deployment',
runnerSettingsUrl: 'settings/ci-cd/runners',
diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js
index bf2f8c05806..66f22162c97 100644
--- a/spec/frontend/jobs/components/log/collapsible_section_spec.js
+++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js
@@ -35,7 +35,7 @@ describe('Job Log Collapsible Section', () => {
});
it('renders an icon with the closed state', () => {
- expect(findCollapsibleLineSvg().classes()).toContain('ic-angle-right');
+ expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('angle-right-icon');
});
});
@@ -52,7 +52,7 @@ describe('Job Log Collapsible Section', () => {
});
it('renders an icon with the open state', () => {
- expect(findCollapsibleLineSvg().classes()).toContain('ic-angle-down');
+ expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('angle-down-icon');
});
it('renders collapsible lines content', () => {
diff --git a/spec/frontend/jobs/components/log/line_header_spec.js b/spec/frontend/jobs/components/log/line_header_spec.js
index 5ce69221dab..bb90949b1f4 100644
--- a/spec/frontend/jobs/components/log/line_header_spec.js
+++ b/spec/frontend/jobs/components/log/line_header_spec.js
@@ -38,7 +38,7 @@ describe('Job Log Header Line', () => {
});
it('renders the line number component', () => {
- expect(wrapper.contains(LineNumber)).toBe(true);
+ expect(wrapper.find(LineNumber).exists()).toBe(true);
});
it('renders a span the provided text', () => {
@@ -90,7 +90,7 @@ describe('Job Log Header Line', () => {
});
it('renders the duration badge', () => {
- expect(wrapper.contains(DurationBadge)).toBe(true);
+ expect(wrapper.find(DurationBadge).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js
index ec3a3968f14..c2412a807c3 100644
--- a/spec/frontend/jobs/components/log/line_spec.js
+++ b/spec/frontend/jobs/components/log/line_spec.js
@@ -35,7 +35,7 @@ describe('Job Log Line', () => {
});
it('renders the line number component', () => {
- expect(wrapper.contains(LineNumber)).toBe(true);
+ expect(wrapper.find(LineNumber).exists()).toBe(true);
});
it('renders a span the provided text', () => {
diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js
index 02cdb31d27e..015d5e01a46 100644
--- a/spec/frontend/jobs/components/log/log_spec.js
+++ b/spec/frontend/jobs/components/log/log_spec.js
@@ -42,6 +42,8 @@ describe('Job Log', () => {
wrapper.destroy();
});
+ const findCollapsibleLine = () => wrapper.find('.collapsible-line');
+
describe('line numbers', () => {
it('renders a line number for each open line', () => {
expect(wrapper.find('#L1').text()).toBe('1');
@@ -56,18 +58,22 @@ describe('Job Log', () => {
describe('collapsible sections', () => {
it('renders a clickable header section', () => {
- expect(wrapper.find('.collapsible-line').attributes('role')).toBe('button');
+ expect(findCollapsibleLine().attributes('role')).toBe('button');
});
it('renders an icon with the open state', () => {
- expect(wrapper.find('.collapsible-line svg').classes()).toContain('ic-angle-down');
+ expect(
+ findCollapsibleLine()
+ .find('[data-testid="angle-down-icon"]')
+ .exists(),
+ ).toBe(true);
});
describe('on click header section', () => {
it('calls toggleCollapsibleLine', () => {
jest.spyOn(wrapper.vm, 'toggleCollapsibleLine');
- wrapper.find('.collapsible-line').trigger('click');
+ findCollapsibleLine().trigger('click');
expect(wrapper.vm.toggleCollapsibleLine).toHaveBeenCalled();
});
diff --git a/spec/frontend/jobs/components/manual_variables_form_spec.js b/spec/frontend/jobs/components/manual_variables_form_spec.js
index 82fd73ef033..547f146cf88 100644
--- a/spec/frontend/jobs/components/manual_variables_form_spec.js
+++ b/spec/frontend/jobs/components/manual_variables_form_spec.js
@@ -1,5 +1,5 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import Form from '~/jobs/components/manual_variables_form.vue';
const localVue = createLocalVue();
@@ -95,7 +95,7 @@ describe('Manual Variables Form', () => {
});
it('removes the variable row', () => {
- wrapper.find(GlDeprecatedButton).vm.$emit('click');
+ wrapper.find(GlButton).vm.$emit('click');
expect(wrapper.vm.variables.length).toBe(0);
});
diff --git a/spec/frontend/jobs/store/helpers.js b/spec/frontend/jobs/store/helpers.js
index 81a769b4a6e..78e33394b63 100644
--- a/spec/frontend/jobs/store/helpers.js
+++ b/spec/frontend/jobs/store/helpers.js
@@ -1,6 +1,5 @@
import state from '~/jobs/store/state';
-// eslint-disable-next-line import/prefer-default-export
export const resetStore = store => {
store.replaceState(state());
};
diff --git a/spec/frontend/labels_issue_sidebar_spec.js b/spec/frontend/labels_issue_sidebar_spec.js
index fafefca94df..f74547c0554 100644
--- a/spec/frontend/labels_issue_sidebar_spec.js
+++ b/spec/frontend/labels_issue_sidebar_spec.js
@@ -7,7 +7,6 @@ import axios from '~/lib/utils/axios_utils';
import IssuableContext from '~/issuable_context';
import LabelsSelect from '~/labels_select';
-import '~/gl_dropdown';
import 'select2';
import '~/api';
import '~/create_label';
diff --git a/spec/frontend/lib/utils/axios_startup_calls_spec.js b/spec/frontend/lib/utils/axios_startup_calls_spec.js
new file mode 100644
index 00000000000..e804cae7914
--- /dev/null
+++ b/spec/frontend/lib/utils/axios_startup_calls_spec.js
@@ -0,0 +1,131 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import setupAxiosStartupCalls from '~/lib/utils/axios_startup_calls';
+
+describe('setupAxiosStartupCalls', () => {
+ const AXIOS_RESPONSE = { text: 'AXIOS_RESPONSE' };
+ const STARTUP_JS_RESPONSE = { text: 'STARTUP_JS_RESPONSE' };
+ let mock;
+
+ function mockFetchCall(status) {
+ const p = {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: new Headers({ 'Content-Type': 'application/json' }),
+ statusText: `MOCK-FETCH ${status}`,
+ clone: () => p,
+ json: () => Promise.resolve(STARTUP_JS_RESPONSE),
+ };
+ return Promise.resolve(p);
+ }
+
+ function mockConsoleWarn() {
+ jest.spyOn(console, 'warn').mockImplementation();
+ }
+
+ function expectConsoleWarn(path) {
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(path), expect.any(Error));
+ }
+
+ beforeEach(() => {
+ window.gl = {};
+ mock = new MockAdapter(axios);
+ mock.onGet('/non-startup').reply(200, AXIOS_RESPONSE);
+ mock.onGet('/startup').reply(200, AXIOS_RESPONSE);
+ mock.onGet('/startup-failing').reply(200, AXIOS_RESPONSE);
+ });
+
+ afterEach(() => {
+ delete window.gl;
+ axios.interceptors.request.handlers = [];
+ mock.restore();
+ });
+
+ it('if no startupCalls are registered: does not register a request interceptor', () => {
+ setupAxiosStartupCalls(axios);
+
+ expect(axios.interceptors.request.handlers.length).toBe(0);
+ });
+
+ describe('if startupCalls are registered', () => {
+ beforeEach(() => {
+ window.gl.startup_calls = {
+ '/startup': {
+ fetchCall: mockFetchCall(200),
+ },
+ '/startup-failing': {
+ fetchCall: mockFetchCall(400),
+ },
+ };
+ setupAxiosStartupCalls(axios);
+ });
+
+ it('registers a request interceptor', () => {
+ expect(axios.interceptors.request.handlers.length).toBe(1);
+ });
+
+ it('detaches the request interceptor if every startup call has been made', async () => {
+ expect(axios.interceptors.request.handlers[0]).not.toBeNull();
+
+ await axios.get('/startup');
+ mockConsoleWarn();
+ await axios.get('/startup-failing');
+
+ // Axios sets the interceptor to null
+ expect(axios.interceptors.request.handlers[0]).toBeNull();
+ });
+
+ it('delegates to startup calls if URL is registered and call is successful', async () => {
+ const { headers, data, status, statusText } = await axios.get('/startup');
+
+ expect(headers).toEqual({ 'content-type': 'application/json' });
+ expect(status).toBe(200);
+ expect(statusText).toBe('MOCK-FETCH 200');
+ expect(data).toEqual(STARTUP_JS_RESPONSE);
+ expect(data).not.toEqual(AXIOS_RESPONSE);
+ });
+
+ it('delegates to startup calls exactly once', async () => {
+ await axios.get('/startup');
+ const { data } = await axios.get('/startup');
+
+ expect(data).not.toEqual(STARTUP_JS_RESPONSE);
+ expect(data).toEqual(AXIOS_RESPONSE);
+ });
+
+ it('does not delegate to startup calls if the call is failing', async () => {
+ mockConsoleWarn();
+ const { data } = await axios.get('/startup-failing');
+
+ expect(data).not.toEqual(STARTUP_JS_RESPONSE);
+ expect(data).toEqual(AXIOS_RESPONSE);
+ expectConsoleWarn('/startup-failing');
+ });
+
+ it('does not delegate to startup call if URL is not registered', async () => {
+ const { data } = await axios.get('/non-startup');
+
+ expect(data).toEqual(AXIOS_RESPONSE);
+ expect(data).not.toEqual(STARTUP_JS_RESPONSE);
+ });
+ });
+
+ it('removes GitLab Base URL from startup call', async () => {
+ const oldGon = window.gon;
+ window.gon = { gitlab_url: 'https://example.org/gitlab' };
+
+ window.gl.startup_calls = {
+ '/startup': {
+ fetchCall: mockFetchCall(200),
+ },
+ };
+ setupAxiosStartupCalls(axios);
+
+ const { data } = await axios.get('https://example.org/gitlab/startup');
+
+ expect(data).toEqual(STARTUP_JS_RESPONSE);
+
+ window.gon = oldGon;
+ });
+});
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 9eb5587e83c..5b1fdea058b 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -653,3 +653,17 @@ describe('differenceInSeconds', () => {
expect(datetimeUtility.differenceInSeconds(startDate, endDate)).toBe(expected);
});
});
+
+describe('differenceInMilliseconds', () => {
+ const startDateTime = new Date('2019-07-17T00:00:00.000Z');
+
+ it.each`
+ startDate | endDate | expected
+ ${startDateTime.getTime()} | ${new Date('2019-07-17T00:00:00.000Z')} | ${0}
+ ${startDateTime} | ${new Date('2019-07-17T12:00:00.000Z').getTime()} | ${43200000}
+ ${startDateTime} | ${new Date('2019-07-18T00:00:00.000Z').getTime()} | ${86400000}
+ ${new Date('2019-07-18T00:00:00.000Z')} | ${startDateTime.getTime()} | ${-86400000}
+ `('returns $expected for $endDate - $startDate', ({ startDate, endDate, expected }) => {
+ expect(datetimeUtility.differenceInMilliseconds(startDate, endDate)).toBe(expected);
+ });
+});
diff --git a/spec/frontend/lib/utils/forms_spec.js b/spec/frontend/lib/utils/forms_spec.js
index 07ba7c29dfc..a69be99ab98 100644
--- a/spec/frontend/lib/utils/forms_spec.js
+++ b/spec/frontend/lib/utils/forms_spec.js
@@ -1,4 +1,4 @@
-import { serializeForm } from '~/lib/utils/forms';
+import { serializeForm, serializeFormObject, isEmptyValue } from '~/lib/utils/forms';
describe('lib/utils/forms', () => {
const createDummyForm = inputs => {
@@ -93,4 +93,46 @@ describe('lib/utils/forms', () => {
});
});
});
+
+ describe('isEmptyValue', () => {
+ it.each`
+ input | returnValue
+ ${''} | ${true}
+ ${[]} | ${true}
+ ${null} | ${true}
+ ${undefined} | ${true}
+ ${'hello'} | ${false}
+ ${' '} | ${false}
+ ${0} | ${false}
+ `('returns $returnValue for value $input', ({ input, returnValue }) => {
+ expect(isEmptyValue(input)).toBe(returnValue);
+ });
+ });
+
+ describe('serializeFormObject', () => {
+ it('returns an serialized object', () => {
+ const form = {
+ profileName: { value: 'hello', state: null, feedback: null },
+ spiderTimeout: { value: 2, state: true, feedback: null },
+ targetTimeout: { value: 12, state: true, feedback: null },
+ };
+ expect(serializeFormObject(form)).toEqual({
+ profileName: 'hello',
+ spiderTimeout: 2,
+ targetTimeout: 12,
+ });
+ });
+
+ it('returns only the entries with value', () => {
+ const form = {
+ profileName: { value: '', state: null, feedback: null },
+ spiderTimeout: { value: 0, state: null, feedback: null },
+ targetTimeout: { value: null, state: null, feedback: null },
+ name: { value: undefined, state: null, feedback: null },
+ };
+ expect(serializeFormObject(form)).toEqual({
+ spiderTimeout: 0,
+ });
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index 2e52958a828..1aaae80dcdf 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -1,4 +1,4 @@
-import { insertMarkdownText } from '~/lib/utils/text_markdown';
+import { insertMarkdownText, keypressNoteText } from '~/lib/utils/text_markdown';
describe('init markdown', () => {
let textArea;
@@ -115,14 +115,15 @@ describe('init markdown', () => {
describe('with selection', () => {
const text = 'initial selected value';
const selected = 'selected';
+ let selectedIndex;
+
beforeEach(() => {
textArea.value = text;
- const selectedIndex = text.indexOf(selected);
+ selectedIndex = text.indexOf(selected);
textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length);
});
it('applies the tag to the selected value', () => {
- const selectedIndex = text.indexOf(selected);
const tag = '*';
insertMarkdownText({
@@ -153,6 +154,29 @@ describe('init markdown', () => {
expect(textArea.value).toEqual(text.replace(selected, `[${selected}](url)`));
});
+ it.each`
+ key | expected
+ ${'['} | ${`[${selected}]`}
+ ${'*'} | ${`**${selected}**`}
+ ${"'"} | ${`'${selected}'`}
+ ${'_'} | ${`_${selected}_`}
+ ${'`'} | ${`\`${selected}\``}
+ ${'"'} | ${`"${selected}"`}
+ ${'{'} | ${`{${selected}}`}
+ ${'('} | ${`(${selected})`}
+ ${'<'} | ${`<${selected}>`}
+ `('generates $expected when $key is pressed', ({ key, expected }) => {
+ const event = new KeyboardEvent('keydown', { key });
+
+ textArea.addEventListener('keydown', keypressNoteText);
+ textArea.dispatchEvent(event);
+
+ expect(textArea.value).toEqual(text.replace(selected, expected));
+
+ // cursor placement should be after selection + 2 tag lengths
+ expect(textArea.selectionStart).toBe(selectedIndex + expected.length);
+ });
+
describe('and text to be selected', () => {
const tag = '[{text}](url)';
const select = 'url';
@@ -178,7 +202,7 @@ describe('init markdown', () => {
it('selects the right text when multiple tags are present', () => {
const initialValue = `${tag} ${tag} ${selected}`;
textArea.value = initialValue;
- const selectedIndex = initialValue.indexOf(selected);
+ selectedIndex = initialValue.indexOf(selected);
textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length);
insertMarkdownText({
textArea,
@@ -204,7 +228,7 @@ describe('init markdown', () => {
const initialValue = `text ${expectedUrl} text`;
textArea.value = initialValue;
- const selectedIndex = initialValue.indexOf(expectedUrl);
+ selectedIndex = initialValue.indexOf(expectedUrl);
textArea.setSelectionRange(selectedIndex, selectedIndex + expectedUrl.length);
insertMarkdownText({
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
index 285f7d04c3b..6fef5f6b63c 100644
--- a/spec/frontend/lib/utils/text_utility_spec.js
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -205,6 +205,27 @@ describe('text_utility', () => {
});
});
+ describe('convertUnicodeToAscii', () => {
+ it('does nothing on an empty string', () => {
+ expect(textUtils.convertUnicodeToAscii('')).toBe('');
+ });
+
+ it('does nothing on an already ascii string', () => {
+ expect(textUtils.convertUnicodeToAscii('The quick brown fox jumps over the lazy dog.')).toBe(
+ 'The quick brown fox jumps over the lazy dog.',
+ );
+ });
+
+ it('replaces Unicode characters', () => {
+ expect(textUtils.convertUnicodeToAscii('Dĭd söméònê äšk fœŕ Ůnĭċődę?')).toBe(
+ 'Did soemeone aesk foer Unicode?',
+ );
+
+ expect(textUtils.convertUnicodeToAscii("Jürgen's Projekt")).toBe("Juergen's Projekt");
+ expect(textUtils.convertUnicodeToAscii('öäüÖÄÜ')).toBe('oeaeueOeAeUe');
+ });
+ });
+
describe('splitCamelCase', () => {
it('separates a PascalCase word to two', () => {
expect(textUtils.splitCamelCase('HelloWorld')).toBe('Hello World');
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index a13ac3778cf..869ae274a3f 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -161,6 +161,15 @@ describe('URL utility', () => {
);
});
+ it('sorts params in alphabetical order with sort option', () => {
+ expect(mergeUrlParams({ c: 'c', b: 'b', a: 'a' }, 'https://host/path', { sort: true })).toBe(
+ 'https://host/path?a=a&b=b&c=c',
+ );
+ expect(
+ mergeUrlParams({ alpha: 'alpha' }, 'https://host/path?op=/&foo=bar', { sort: true }),
+ ).toBe('https://host/path?alpha=alpha&foo=bar&op=%2F');
+ });
+
describe('with spread array option', () => {
const spreadArrayOptions = { spreadArrays: true };
@@ -616,6 +625,35 @@ describe('URL utility', () => {
expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' });
});
+
+ describe('with gatherArrays=false', () => {
+ it('overwrites values with the same array-key and does not change the key', () => {
+ const searchQuery = '?one[]=1&one[]=2&two=2&two=3';
+
+ expect(urlUtils.queryToObject(searchQuery)).toEqual({ 'one[]': '2', two: '3' });
+ });
+ });
+
+ describe('with gatherArrays=true', () => {
+ const options = { gatherArrays: true };
+ it('gathers only values with the same array-key and strips `[]` from the key', () => {
+ const searchQuery = '?one[]=1&one[]=2&two=2&two=3';
+
+ expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: ['1', '2'], two: '3' });
+ });
+
+ it('overwrites values with the same array-key name', () => {
+ const searchQuery = '?one=1&one[]=2&two=2&two=3';
+
+ expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: ['2'], two: '3' });
+ });
+
+ it('overwrites values with the same key name', () => {
+ const searchQuery = '?one[]=1&one=2&two=2&two=3';
+
+ expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: '2', two: '3' });
+ });
+ });
});
describe('objectToQuery', () => {
diff --git a/spec/frontend/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js
index 6421aca684f..559ce4f9414 100644
--- a/spec/frontend/logs/components/environment_logs_spec.js
+++ b/spec/frontend/logs/components/environment_logs_spec.js
@@ -39,13 +39,22 @@ describe('EnvironmentLogs', () => {
};
const updateControlBtnsMock = jest.fn();
+ const LogControlButtonsStub = {
+ template: '<div/>',
+ methods: {
+ update: updateControlBtnsMock,
+ },
+ props: {
+ scrollDownButtonDisabled: false,
+ },
+ };
const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
const findSimpleFilters = () => wrapper.find({ ref: 'log-simple-filters' });
const findAdvancedFilters = () => wrapper.find({ ref: 'log-advanced-filters' });
const findElasticsearchNotice = () => wrapper.find({ ref: 'elasticsearchNotice' });
- const findLogControlButtons = () => wrapper.find({ name: 'log-control-buttons-stub' });
+ const findLogControlButtons = () => wrapper.find(LogControlButtonsStub);
const findInfiniteScroll = () => wrapper.find({ ref: 'infiniteScroll' });
const findLogTrace = () => wrapper.find({ ref: 'logTrace' });
@@ -76,16 +85,7 @@ describe('EnvironmentLogs', () => {
propsData,
store,
stubs: {
- LogControlButtons: {
- name: 'log-control-buttons-stub',
- template: '<div/>',
- methods: {
- update: updateControlBtnsMock,
- },
- props: {
- scrollDownButtonDisabled: false,
- },
- },
+ LogControlButtons: LogControlButtonsStub,
GlInfiniteScroll: {
name: 'gl-infinite-scroll',
template: `
@@ -121,9 +121,6 @@ describe('EnvironmentLogs', () => {
it('displays UI elements', () => {
initWrapper();
- expect(wrapper.isVueInstance()).toBe(true);
- expect(wrapper.isEmpty()).toBe(false);
-
expect(findEnvironmentsDropdown().is(GlDeprecatedDropdown)).toBe(true);
expect(findSimpleFilters().exists()).toBe(true);
expect(findLogControlButtons().exists()).toBe(true);
diff --git a/spec/frontend/logs/components/log_advanced_filters_spec.js b/spec/frontend/logs/components/log_advanced_filters_spec.js
index 007c5000e16..3a3c23c95b8 100644
--- a/spec/frontend/logs/components/log_advanced_filters_spec.js
+++ b/spec/frontend/logs/components/log_advanced_filters_spec.js
@@ -68,9 +68,6 @@ describe('LogAdvancedFilters', () => {
it('displays UI elements', () => {
initWrapper();
- expect(wrapper.isVueInstance()).toBe(true);
- expect(wrapper.isEmpty()).toBe(false);
-
expect(findFilteredSearch().exists()).toBe(true);
expect(findTimeRangePicker().exists()).toBe(true);
});
diff --git a/spec/frontend/logs/components/log_control_buttons_spec.js b/spec/frontend/logs/components/log_control_buttons_spec.js
index 38e568f569f..dff38ecb15e 100644
--- a/spec/frontend/logs/components/log_control_buttons_spec.js
+++ b/spec/frontend/logs/components/log_control_buttons_spec.js
@@ -28,9 +28,6 @@ describe('LogControlButtons', () => {
it('displays UI elements', () => {
initWrapper();
- expect(wrapper.isVueInstance()).toBe(true);
- expect(wrapper.isEmpty()).toBe(false);
-
expect(findScrollToTop().is(GlButton)).toBe(true);
expect(findScrollToBottom().is(GlButton)).toBe(true);
expect(findRefreshBtn().is(GlButton)).toBe(true);
@@ -57,7 +54,7 @@ describe('LogControlButtons', () => {
});
it('click on "scroll to top" scrolls up', () => {
- expect(findScrollToTop().is('[disabled]')).toBe(false);
+ expect(findScrollToTop().attributes('disabled')).toBeUndefined();
findScrollToTop().vm.$emit('click');
@@ -65,7 +62,7 @@ describe('LogControlButtons', () => {
});
it('click on "scroll to bottom" scrolls down', () => {
- expect(findScrollToBottom().is('[disabled]')).toBe(false);
+ expect(findScrollToBottom().attributes('disabled')).toBeUndefined();
findScrollToBottom().vm.$emit('click');
diff --git a/spec/frontend/logs/components/log_simple_filters_spec.js b/spec/frontend/logs/components/log_simple_filters_spec.js
index e739621431e..1e30a7df559 100644
--- a/spec/frontend/logs/components/log_simple_filters_spec.js
+++ b/spec/frontend/logs/components/log_simple_filters_spec.js
@@ -18,7 +18,7 @@ describe('LogSimpleFilters', () => {
const findPodsDropdownItems = () =>
findPodsDropdown()
.findAll(GlDeprecatedDropdownItem)
- .filter(item => !item.is('[disabled]'));
+ .filter(item => !('disabled' in item.attributes()));
const mockPodsLoading = () => {
state.pods.options = [];
@@ -59,9 +59,6 @@ describe('LogSimpleFilters', () => {
it('displays UI elements', () => {
initWrapper();
- expect(wrapper.isVueInstance()).toBe(true);
- expect(wrapper.isEmpty()).toBe(false);
-
expect(findPodsDropdown().exists()).toBe(true);
});
diff --git a/spec/frontend/logs/mock_data.js b/spec/frontend/logs/mock_data.js
index f4c567a2ea3..3fabab4bc59 100644
--- a/spec/frontend/logs/mock_data.js
+++ b/spec/frontend/logs/mock_data.js
@@ -35,6 +35,7 @@ export const mockManagedApps = [
status: 'connected',
path: '/root/autodevops-deploy/-/clusters/15',
gitlab_managed_apps_logs_path: '/root/autodevops-deploy/-/logs?cluster_id=15',
+ enable_advanced_logs_querying: true,
},
{
cluster_type: 'project_type',
@@ -45,6 +46,7 @@ export const mockManagedApps = [
status: 'connected',
path: '/root/autodevops-deploy/-/clusters/16',
gitlab_managed_apps_logs_path: null,
+ enable_advanced_logs_querying: false,
},
];
diff --git a/spec/frontend/logs/stores/getters_spec.js b/spec/frontend/logs/stores/getters_spec.js
index 9d213d8c01f..bca1ce4ca92 100644
--- a/spec/frontend/logs/stores/getters_spec.js
+++ b/spec/frontend/logs/stores/getters_spec.js
@@ -1,7 +1,14 @@
import { trace, showAdvancedFilters } from '~/logs/stores/getters';
import logsPageState from '~/logs/stores/state';
-import { mockLogsResult, mockTrace, mockEnvName, mockEnvironments } from '../mock_data';
+import {
+ mockLogsResult,
+ mockTrace,
+ mockEnvName,
+ mockEnvironments,
+ mockManagedApps,
+ mockManagedAppName,
+} from '../mock_data';
describe('Logs Store getters', () => {
let state;
@@ -72,4 +79,43 @@ describe('Logs Store getters', () => {
});
});
});
+
+ describe('when no managedApps are set', () => {
+ beforeEach(() => {
+ state.environments.current = null;
+ state.environments.options = [];
+ state.managedApps.current = mockManagedAppName;
+ state.managedApps.options = [];
+ });
+
+ it('returns false', () => {
+ expect(showAdvancedFilters(state)).toBe(false);
+ });
+ });
+
+ describe('when the managedApp supports filters', () => {
+ beforeEach(() => {
+ state.environments.current = null;
+ state.environments.options = mockEnvironments;
+ state.managedApps.current = mockManagedAppName;
+ state.managedApps.options = mockManagedApps;
+ });
+
+ it('returns true', () => {
+ expect(showAdvancedFilters(state)).toBe(true);
+ });
+ });
+
+ describe('when the managedApp does not support filters', () => {
+ beforeEach(() => {
+ state.environments.current = null;
+ state.environments.options = mockEnvironments;
+ state.managedApps.options = mockManagedApps;
+ state.managedApps.current = mockManagedApps[1].name;
+ });
+
+ it('returns false', () => {
+ expect(showAdvancedFilters(state)).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/matchers.js b/spec/frontend/matchers.js
index 53c6a72eea0..50feba86a61 100644
--- a/spec/frontend/matchers.js
+++ b/spec/frontend/matchers.js
@@ -9,8 +9,8 @@ export default {
}
const iconReferences = [].slice.apply(element.querySelectorAll('svg use'));
- const matchingIcon = iconReferences.find(reference =>
- reference.getAttribute('xlink:href').endsWith(`#${iconName}`),
+ const matchingIcon = iconReferences.find(
+ reference => reference.parentNode.getAttribute('data-testid') === `${iconName}-icon`,
);
const pass = Boolean(matchingIcon);
@@ -22,7 +22,7 @@ export default {
message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`;
const existingIcons = iconReferences.map(reference => {
- const iconUrl = reference.getAttribute('xlink:href');
+ const iconUrl = reference.getAttribute('href');
return `"${iconUrl.replace(/^.+#/, '')}"`;
});
if (existingIcons.length > 0) {
diff --git a/spec/frontend/milestones/project_milestone_combobox_spec.js b/spec/frontend/milestones/project_milestone_combobox_spec.js
index 2265c9bdc2e..60d68aa5816 100644
--- a/spec/frontend/milestones/project_milestone_combobox_spec.js
+++ b/spec/frontend/milestones/project_milestone_combobox_spec.js
@@ -1,11 +1,13 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
-import { GlNewDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+import { GlDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+import { ENTER_KEY } from '~/lib/utils/keys';
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
import { milestones as projectMilestones } from './mock_data';
const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search';
+const TEST_SEARCH = 'TEST_SEARCH';
const extraLinks = [
{ text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' },
@@ -21,6 +23,8 @@ describe('Milestone selector', () => {
const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' });
+ const findSearchBox = () => wrapper.find(GlSearchBoxByType);
+
const factory = (options = {}) => {
wrapper = shallowMount(MilestoneCombobox, {
...options,
@@ -49,7 +53,7 @@ describe('Milestone selector', () => {
});
it('renders the dropdown', () => {
- expect(wrapper.find(GlNewDropdown)).toExist();
+ expect(wrapper.find(GlDropdown)).toExist();
});
it('renders additional links', () => {
@@ -63,7 +67,7 @@ describe('Milestone selector', () => {
describe('before results', () => {
it('should show a loading icon', () => {
const request = mock.onGet(TEST_SEARCH_ENDPOINT, {
- params: { search: 'TEST_SEARCH', scope: 'milestones' },
+ params: { search: TEST_SEARCH, scope: 'milestones' },
});
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
@@ -85,9 +89,9 @@ describe('Milestone selector', () => {
describe('with empty results', () => {
beforeEach(() => {
mock
- .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } })
+ .onGet(TEST_SEARCH_ENDPOINT, { params: { search: TEST_SEARCH, scope: 'milestones' } })
.reply(200, []);
- wrapper.find(GlSearchBoxByType).vm.$emit('input', 'TEST_SEARCH');
+ findSearchBox().vm.$emit('input', TEST_SEARCH);
return axios.waitForAll();
});
@@ -116,7 +120,7 @@ describe('Milestone selector', () => {
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6',
},
]);
- wrapper.find(GlSearchBoxByType).vm.$emit('input', 'v0.1');
+ findSearchBox().vm.$emit('input', 'v0.1');
return axios.waitForAll().then(() => {
items = wrapper.findAll('[role="milestone option"]');
});
@@ -147,4 +151,36 @@ describe('Milestone selector', () => {
expect(findNoResultsMessage().exists()).toBe(false);
});
});
+
+ describe('when Enter is pressed', () => {
+ beforeEach(() => {
+ factory({
+ propsData: {
+ projectId,
+ preselectedMilestones,
+ extraLinks,
+ },
+ data() {
+ return {
+ searchQuery: 'TEST_SEARCH',
+ };
+ },
+ });
+
+ mock
+ .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } })
+ .reply(200, []);
+ });
+
+ it('should trigger a search', async () => {
+ mock.resetHistory();
+
+ findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+
+ await axios.waitForAll();
+
+ expect(mock.history.get.length).toBe(1);
+ expect(mock.history.get[0].url).toBe(TEST_SEARCH_ENDPOINT);
+ });
+ });
});
diff --git a/spec/frontend/mocks/mocks_helper.js b/spec/frontend/mocks/mocks_helper.js
index 823ab41a5ba..0aa80331434 100644
--- a/spec/frontend/mocks/mocks_helper.js
+++ b/spec/frontend/mocks/mocks_helper.js
@@ -29,7 +29,6 @@ const getMockFiles = root => readdir.sync(root, { deep: MAX_DEPTH, filter: mockF
const defaultSetMock = (srcPath, mockPath) =>
jest.mock(srcPath, () => jest.requireActual(mockPath));
-// eslint-disable-next-line import/prefer-default-export
export const setupManualMocks = function setupManualMocks(setMock = defaultSetMock) {
prefixMap.forEach(({ mocksRoot, requirePrefix }) => {
const mocksRootAbsolute = path.join(__dirname, mocksRoot);
diff --git a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap
index 59c17daacff..2a8ce1d3f30 100644
--- a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap
+++ b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap
@@ -13,7 +13,7 @@ exports[`AlertWidget Alert firing displays a warning icon and matches snapshot 1
/>
<span
- class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me"
+ class="text-truncate gl-pl-2"
>
Firing:
alert-label &gt; 42
@@ -35,7 +35,7 @@ exports[`AlertWidget Alert not firing displays a warning icon and matches snapsh
/>
<span
- class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me"
+ class="text-truncate gl-pl-2"
>
alert-label &gt; 42
</span>
diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js
index 193dbb3e63f..d004b1da0b6 100644
--- a/spec/frontend/monitoring/alert_widget_spec.js
+++ b/spec/frontend/monitoring/alert_widget_spec.js
@@ -84,7 +84,7 @@ describe('AlertWidget', () => {
},
});
};
- const hasLoadingIcon = () => wrapper.contains(GlLoadingIcon);
+ const hasLoadingIcon = () => wrapper.find(GlLoadingIcon).exists();
const findWidgetForm = () => wrapper.find({ ref: 'widgetForm' });
const findAlertErrorMessage = () => wrapper.find({ ref: 'alertErrorMessage' });
const findCurrentSettingsText = () =>
diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
index 7ef956f8e05..a28ecac00fd 100644
--- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
@@ -32,7 +32,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
<div
class="mb-2 pr-2 d-flex d-sm-block"
>
- <gl-new-dropdown-stub
+ <gl-dropdown-stub
category="tertiary"
class="flex-grow-1"
data-qa-selector="environments_dropdown"
@@ -47,12 +47,12 @@ exports[`Dashboard template matches the default snapshot 1`] = `
<div
class="d-flex flex-column overflow-hidden"
>
- <gl-new-dropdown-header-stub>
+ <gl-dropdown-section-header-stub>
Environment
- </gl-new-dropdown-header-stub>
+ </gl-dropdown-section-header-stub>
<gl-search-box-by-type-stub
- class="m-2"
+ class="gl-m-3"
clearbuttontitle="Clear"
value=""
/>
@@ -69,7 +69,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
</div>
</div>
- </gl-new-dropdown-stub>
+ </gl-dropdown-stub>
</div>
<div
diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js
index 15a52d03bcd..ebb49a2a0aa 100644
--- a/spec/frontend/monitoring/components/charts/anomaly_spec.js
+++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js
@@ -46,9 +46,8 @@ describe('Anomaly chart component', () => {
});
});
- it('is a Vue instance', () => {
+ it('renders correctly', () => {
expect(findTimeSeries().exists()).toBe(true);
- expect(findTimeSeries().isVueInstance()).toBe(true);
});
describe('receives props correctly', () => {
diff --git a/spec/frontend/monitoring/components/charts/bar_spec.js b/spec/frontend/monitoring/components/charts/bar_spec.js
index e39e6e7e2c2..a363fafdc31 100644
--- a/spec/frontend/monitoring/components/charts/bar_spec.js
+++ b/spec/frontend/monitoring/components/charts/bar_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { GlBarChart } from '@gitlab/ui/dist/charts';
import Bar from '~/monitoring/components/charts/bar.vue';
-import { barMockData } from '../../mock_data';
+import { barGraphData } from '../../graph_data';
jest.mock('~/lib/utils/icon_utils', () => ({
getSvgIconPathContent: jest.fn().mockResolvedValue('mockSvgPathContent'),
@@ -10,11 +10,14 @@ jest.mock('~/lib/utils/icon_utils', () => ({
describe('Bar component', () => {
let barChart;
let store;
+ let graphData;
beforeEach(() => {
+ graphData = barGraphData();
+
barChart = shallowMount(Bar, {
propsData: {
- graphData: barMockData,
+ graphData,
},
store,
});
@@ -31,15 +34,11 @@ describe('Bar component', () => {
beforeEach(() => {
glbarChart = barChart.find(GlBarChart);
- chartData = barChart.vm.chartData[barMockData.metrics[0].label];
- });
-
- it('is a Vue instance', () => {
- expect(glbarChart.isVueInstance()).toBe(true);
+ chartData = barChart.vm.chartData[graphData.metrics[0].label];
});
it('should display a label on the x axis', () => {
- expect(glbarChart.vm.xAxisTitle).toBe(barMockData.xLabel);
+ expect(glbarChart.props('xAxisTitle')).toBe(graphData.xLabel);
});
it('should return chartData as array of arrays', () => {
diff --git a/spec/frontend/monitoring/components/charts/column_spec.js b/spec/frontend/monitoring/components/charts/column_spec.js
index a2056d96dcf..16e2080c000 100644
--- a/spec/frontend/monitoring/components/charts/column_spec.js
+++ b/spec/frontend/monitoring/components/charts/column_spec.js
@@ -95,10 +95,6 @@ describe('Column component', () => {
describe('wrapped components', () => {
describe('GitLab UI column chart', () => {
- it('is a Vue instance', () => {
- expect(findChart().isVueInstance()).toBe(true);
- });
-
it('receives data properties needed for proper chart render', () => {
expect(chartProps('data').values).toEqual(dataValues);
});
diff --git a/spec/frontend/monitoring/components/charts/heatmap_spec.js b/spec/frontend/monitoring/components/charts/heatmap_spec.js
index 27a2021e9be..c8375810a7b 100644
--- a/spec/frontend/monitoring/components/charts/heatmap_spec.js
+++ b/spec/frontend/monitoring/components/charts/heatmap_spec.js
@@ -24,21 +24,14 @@ describe('Heatmap component', () => {
};
describe('wrapped chart', () => {
- let glHeatmapChart;
-
beforeEach(() => {
createWrapper();
- glHeatmapChart = findChart();
});
afterEach(() => {
wrapper.destroy();
});
- it('is a Vue instance', () => {
- expect(glHeatmapChart.isVueInstance()).toBe(true);
- });
-
it('should display a label on the x axis', () => {
expect(wrapper.vm.xAxisName).toBe(graphData.xLabel);
});
diff --git a/spec/frontend/monitoring/components/charts/stacked_column_spec.js b/spec/frontend/monitoring/components/charts/stacked_column_spec.js
index bb2fbc68eaa..24a2af87eb8 100644
--- a/spec/frontend/monitoring/components/charts/stacked_column_spec.js
+++ b/spec/frontend/monitoring/components/charts/stacked_column_spec.js
@@ -3,13 +3,15 @@ import timezoneMock from 'timezone-mock';
import { cloneDeep } from 'lodash';
import { GlStackedColumnChart, GlChartLegend } from '@gitlab/ui/dist/charts';
import StackedColumnChart from '~/monitoring/components/charts/stacked_column.vue';
-import { stackedColumnMockedData } from '../../mock_data';
+import { stackedColumnGraphData } from '../../graph_data';
jest.mock('~/lib/utils/icon_utils', () => ({
getSvgIconPathContent: jest.fn().mockImplementation(icon => Promise.resolve(`${icon}-content`)),
}));
describe('Stacked column chart component', () => {
+ const stackedColumnMockedData = stackedColumnGraphData();
+
let wrapper;
const findChart = () => wrapper.find(GlStackedColumnChart);
@@ -63,9 +65,9 @@ describe('Stacked column chart component', () => {
const groupBy = findChart().props('groupBy');
expect(groupBy).toEqual([
- '2020-01-30T12:00:00.000Z',
- '2020-01-30T12:01:00.000Z',
- '2020-01-30T12:02:00.000Z',
+ '2015-07-01T20:10:50.000Z',
+ '2015-07-01T20:12:50.000Z',
+ '2015-07-01T20:14:50.000Z',
]);
});
diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js
index 6f9a89feb3e..7f0ff534db3 100644
--- a/spec/frontend/monitoring/components/charts/time_series_spec.js
+++ b/spec/frontend/monitoring/components/charts/time_series_spec.js
@@ -632,9 +632,8 @@ describe('Time series component', () => {
return wrapper.vm.$nextTick();
});
- it('is a Vue instance', () => {
+ it('exists', () => {
expect(findChartComponent().exists()).toBe(true);
- expect(findChartComponent().isVueInstance()).toBe(true);
});
it('receives data properties needed for proper chart render', () => {
diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
index 024b2cbd7f1..b22e05ec30a 100644
--- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlNewDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import { createStore } from '~/monitoring/stores';
import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants';
import { setupAllDashboards, setupStoreWithData } from '../store_utils';
@@ -146,8 +146,8 @@ describe('Actions menu', () => {
});
describe('add panel item', () => {
- const GlNewDropdownItemStub = {
- extends: GlNewDropdownItem,
+ const GlDropdownItemStub = {
+ extends: GlDropdownItem,
props: {
to: [String, Object],
},
@@ -164,7 +164,7 @@ describe('Actions menu', () => {
},
{
mocks: { $route },
- stubs: { GlNewDropdownItem: GlNewDropdownItemStub },
+ stubs: { GlDropdownItem: GlDropdownItemStub },
},
);
});
diff --git a/spec/frontend/monitoring/components/dashboard_header_spec.js b/spec/frontend/monitoring/components/dashboard_header_spec.js
index 5cf24706ebd..f9a7a4d5a93 100644
--- a/spec/frontend/monitoring/components/dashboard_header_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_header_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlNewDropdownItem, GlSearchBoxByType, GlLoadingIcon, GlButton } from '@gitlab/ui';
+import { GlDropdownItem, GlSearchBoxByType, GlLoadingIcon, GlButton } from '@gitlab/ui';
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';
@@ -31,7 +31,7 @@ describe('Dashboard header', () => {
const findDashboardDropdown = () => wrapper.find(DashboardsDropdown);
const findEnvsDropdown = () => wrapper.find({ ref: 'monitorEnvironmentsDropdown' });
- const findEnvsDropdownItems = () => findEnvsDropdown().findAll(GlNewDropdownItem);
+ const findEnvsDropdownItems = () => findEnvsDropdown().findAll(GlDropdownItem);
const findEnvsDropdownSearch = () => findEnvsDropdown().find(GlSearchBoxByType);
const findEnvsDropdownSearchMsg = () => wrapper.find({ ref: 'monitorEnvironmentsDropdownMsg' });
const findEnvsDropdownLoadingIcon = () => findEnvsDropdown().find(GlLoadingIcon);
diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js
index 587ddd23d3f..08c69701bd2 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js
@@ -68,7 +68,7 @@ describe('dashboard invalid url parameters', () => {
it('form exists and can be submitted', () => {
expect(findForm().exists()).toBe(true);
expect(findSubmitBtn().exists()).toBe(true);
- expect(findSubmitBtn().is('[disabled]')).toBe(false);
+ expect(findSubmitBtn().props('disabled')).toBe(false);
});
it('form has a text area with a default value', () => {
@@ -109,7 +109,7 @@ describe('dashboard invalid url parameters', () => {
});
it('submit button is disabled', () => {
- expect(findSubmitBtn().is('[disabled]')).toBe(true);
+ expect(findSubmitBtn().props('disabled')).toBe(true);
});
});
});
diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js
index fb96bcc042f..8947a6c1570 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js
@@ -2,7 +2,7 @@ import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { setTestTimeout } from 'helpers/timeout';
-import { GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import invalidUrl from '~/lib/utils/invalid_url';
import axios from '~/lib/utils/axios_utils';
import AlertWidget from '~/monitoring/components/alert_widget.vue';
@@ -15,10 +15,14 @@ import {
mockNamespace,
mockNamespacedData,
mockTimeRange,
- barMockData,
} from '../mock_data';
import { dashboardProps, graphData, graphDataEmpty } from '../fixture_data';
-import { anomalyGraphData, singleStatGraphData, heatmapGraphData } from '../graph_data';
+import {
+ anomalyGraphData,
+ singleStatGraphData,
+ heatmapGraphData,
+ barGraphData,
+} from '../graph_data';
import { panelTypes } from '~/monitoring/constants';
@@ -137,7 +141,6 @@ describe('Dashboard Panel', () => {
it('The Empty Chart component is rendered and is a Vue instance', () => {
expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true);
- expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true);
});
});
@@ -166,7 +169,6 @@ describe('Dashboard Panel', () => {
it('The Empty Chart component is rendered and is a Vue instance', () => {
expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true);
- expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true);
});
});
@@ -222,13 +224,11 @@ describe('Dashboard Panel', () => {
it('empty chart is rendered for empty results', () => {
createWrapper({ graphData: graphDataEmpty });
expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true);
- expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true);
});
it('area chart is rendered by default', () => {
createWrapper();
expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true);
- expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true);
});
describe.each`
@@ -240,7 +240,7 @@ describe('Dashboard Panel', () => {
${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} | ${false}
${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} | ${false}
${heatmapGraphData()} | ${MonitorHeatmapChart} | ${false}
- ${barMockData} | ${MonitorBarChart} | ${false}
+ ${barGraphData()} | ${MonitorBarChart} | ${false}
`('when $data.type data is provided', ({ data, component, hasCtxMenu }) => {
const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' };
@@ -250,7 +250,6 @@ describe('Dashboard Panel', () => {
it(`renders the chart component and binds attributes`, () => {
expect(wrapper.find(component).exists()).toBe(true);
- expect(wrapper.find(component).isVueInstance()).toBe(true);
expect(wrapper.find(component).attributes()).toMatchObject(attrs);
});
@@ -544,7 +543,6 @@ describe('Dashboard Panel', () => {
});
it('it renders a time series chart with no errors', () => {
- expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true);
expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true);
});
});
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index f37d95317ab..b7a0ea46b61 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -645,7 +645,7 @@ describe('Dashboard', () => {
it('it enables draggables', () => {
expect(findRearrangeButton().attributes('pressed')).toBeTruthy();
- expect(findEnabledDraggables()).toEqual(findDraggables());
+ expect(findEnabledDraggables().wrappers).toEqual(findDraggables().wrappers);
});
it('metrics can be swapped', () => {
@@ -668,7 +668,11 @@ describe('Dashboard', () => {
});
it('shows a remove button, which removes a panel', () => {
- expect(findFirstDraggableRemoveButton().isEmpty()).toBe(false);
+ expect(
+ findFirstDraggableRemoveButton()
+ .find('a')
+ .exists(),
+ ).toBe(true);
expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount);
findFirstDraggableRemoveButton().trigger('click');
@@ -703,8 +707,7 @@ describe('Dashboard', () => {
});
it('renders correctly', () => {
- expect(wrapper.isVueInstance()).toBe(true);
- expect(wrapper.exists()).toBe(true);
+ expect(wrapper.html()).not.toBe('');
});
});
diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js
index 89adbad386f..ef5784183b2 100644
--- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js
+++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlNewDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlDropdownItem, GlIcon } from '@gitlab/ui';
import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue';
@@ -33,8 +33,8 @@ describe('DashboardsDropdown', () => {
});
}
- const findItems = () => wrapper.findAll(GlNewDropdownItem);
- const findItemAt = i => wrapper.findAll(GlNewDropdownItem).at(i);
+ const findItems = () => wrapper.findAll(GlDropdownItem);
+ const findItemAt = i => wrapper.findAll(GlDropdownItem).at(i);
const findSearchInput = () => wrapper.find({ ref: 'monitorDashboardsDropdownSearch' });
const findNoItemsMsg = () => wrapper.find({ ref: 'monitorDashboardsDropdownMsg' });
const findStarredListDivider = () => wrapper.find({ ref: 'starredListDivider' });
diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
index 29e4c4514fe..29115ffb817 100644
--- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
+++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
@@ -50,7 +50,7 @@ describe('DuplicateDashboardForm', () => {
it('when is empty', () => {
setValue('fileName', '');
return wrapper.vm.$nextTick(() => {
- expect(findByRef('fileNameFormGroup').is('.is-valid')).toBe(true);
+ expect(findByRef('fileNameFormGroup').classes()).toContain('is-valid');
expect(findInvalidFeedback().exists()).toBe(false);
});
});
@@ -58,7 +58,7 @@ describe('DuplicateDashboardForm', () => {
it('when is valid', () => {
setValue('fileName', 'my_dashboard.yml');
return wrapper.vm.$nextTick(() => {
- expect(findByRef('fileNameFormGroup').is('.is-valid')).toBe(true);
+ expect(findByRef('fileNameFormGroup').classes()).toContain('is-valid');
expect(findInvalidFeedback().exists()).toBe(false);
});
});
@@ -66,7 +66,7 @@ describe('DuplicateDashboardForm', () => {
it('when is not valid', () => {
setValue('fileName', 'my_dashboard.exe');
return wrapper.vm.$nextTick(() => {
- expect(findByRef('fileNameFormGroup').is('.is-invalid')).toBe(true);
+ expect(findByRef('fileNameFormGroup').classes()).toContain('is-invalid');
expect(findInvalidFeedback().text()).toBeTruthy();
});
});
@@ -144,7 +144,7 @@ describe('DuplicateDashboardForm', () => {
return wrapper.vm.$nextTick().then(() => {
wrapper.find('form').trigger('change');
- expect(findByRef('branchName').is(':focus')).toBe(true);
+ expect(document.activeElement).toBe(findByRef('branchName').element);
});
});
});
diff --git a/spec/frontend/monitoring/components/embeds/embed_group_spec.js b/spec/frontend/monitoring/components/embeds/embed_group_spec.js
index 49c10483c45..b63995ec2d4 100644
--- a/spec/frontend/monitoring/components/embeds/embed_group_spec.js
+++ b/spec/frontend/monitoring/components/embeds/embed_group_spec.js
@@ -1,6 +1,6 @@
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
-import { GlDeprecatedButton, GlCard } from '@gitlab/ui';
+import { GlButton, GlCard } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import EmbedGroup from '~/monitoring/components/embeds/embed_group.vue';
import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue';
@@ -71,16 +71,16 @@ describe('Embed Group', () => {
it('is expanded by default', () => {
metricsWithDataGetter.mockReturnValue([1]);
- mountComponent({ shallow: false, stubs: { MetricEmbed: '<div />' } });
+ mountComponent({ shallow: false, stubs: { MetricEmbed: true } });
expect(wrapper.find('.card-body').classes()).not.toContain('d-none');
});
it('collapses when clicked', done => {
metricsWithDataGetter.mockReturnValue([1]);
- mountComponent({ shallow: false, stubs: { MetricEmbed: '<div />' } });
+ mountComponent({ shallow: false, stubs: { MetricEmbed: true } });
- wrapper.find(GlDeprecatedButton).trigger('click');
+ wrapper.find(GlButton).trigger('click');
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.card-body').classes()).toContain('d-none');
@@ -148,16 +148,16 @@ describe('Embed Group', () => {
describe('button text', () => {
it('has a singular label when there is one embed', () => {
metricsWithDataGetter.mockReturnValue([1]);
- mountComponent({ shallow: false, stubs: { MetricEmbed: '<div />' } });
+ mountComponent({ shallow: false, stubs: { MetricEmbed: true } });
- expect(wrapper.find(GlDeprecatedButton).text()).toBe('Hide chart');
+ expect(wrapper.find(GlButton).text()).toBe('Hide chart');
});
it('has a plural label when there are multiple embeds', () => {
metricsWithDataGetter.mockReturnValue([2]);
- mountComponent({ shallow: false, stubs: { MetricEmbed: '<div />' } });
+ mountComponent({ shallow: false, stubs: { MetricEmbed: true } });
- expect(wrapper.find(GlDeprecatedButton).text()).toBe('Hide charts');
+ expect(wrapper.find(GlButton).text()).toBe('Hide charts');
});
});
});
diff --git a/spec/frontend/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js
index 86e2523f708..ebcd6c0df3a 100644
--- a/spec/frontend/monitoring/components/graph_group_spec.js
+++ b/spec/frontend/monitoring/components/graph_group_spec.js
@@ -50,7 +50,7 @@ describe('Graph group component', () => {
it('should contain a tab index for the collapse button', () => {
const groupToggle = findToggleButton();
- expect(groupToggle.is('[tabindex]')).toBe(true);
+ expect(groupToggle.attributes('tabindex')).toBeDefined();
});
it('should show the open the group when collapseGroup is set to true', () => {
diff --git a/spec/frontend/monitoring/components/refresh_button_spec.js b/spec/frontend/monitoring/components/refresh_button_spec.js
index a9b8295f38e..8a478362b5e 100644
--- a/spec/frontend/monitoring/components/refresh_button_spec.js
+++ b/spec/frontend/monitoring/components/refresh_button_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import Visibility from 'visibilityjs';
-import { GlNewDropdown, GlNewDropdownItem, GlButton } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui';
import { createStore } from '~/monitoring/stores';
import RefreshButton from '~/monitoring/components/refresh_button.vue';
@@ -15,8 +15,8 @@ describe('RefreshButton', () => {
};
const findRefreshBtn = () => wrapper.find(GlButton);
- const findDropdown = () => wrapper.find(GlNewDropdown);
- const findOptions = () => findDropdown().findAll(GlNewDropdownItem);
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findOptions = () => findDropdown().findAll(GlDropdownItem);
const findOptionAt = index => findOptions().at(index);
const expectFetchDataToHaveBeenCalledTimes = times => {
diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js
index 30040d3f89f..18ec74550b4 100644
--- a/spec/frontend/monitoring/fixture_data.js
+++ b/spec/frontend/monitoring/fixture_data.js
@@ -1,8 +1,7 @@
import { stateAndPropsFromDataset } from '~/monitoring/utils';
import { mapToDashboardViewModel } from '~/monitoring/stores/utils';
import { metricStates } from '~/monitoring/constants';
-import { convertObjectProps } from '~/lib/utils/common_utils';
-import { convertToCamelCase } from '~/lib/utils/text_utility';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { metricsResult } from './mock_data';
@@ -14,13 +13,7 @@ export const metricsDashboardResponse = getJSONFixture(
export const metricsDashboardPayload = metricsDashboardResponse.dashboard;
const datasetState = stateAndPropsFromDataset(
- // It's preferable to have props in snake_case, this will be addressed at:
- // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33574
- convertObjectProps(
- // Some props use kebab-case, convert to snake_case first
- key => convertToCamelCase(key.replace(/-/g, '_')),
- metricsDashboardResponse.metrics_data,
- ),
+ convertObjectPropsToCamelCase(metricsDashboardResponse.metrics_data),
);
// new properties like addDashboardDocumentationPath prop and alertsEndpoint
diff --git a/spec/frontend/monitoring/graph_data.js b/spec/frontend/monitoring/graph_data.js
index f85351e55d7..494fdb1b159 100644
--- a/spec/frontend/monitoring/graph_data.js
+++ b/spec/frontend/monitoring/graph_data.js
@@ -246,3 +246,29 @@ export const gaugeChartGraphData = (panelOptions = {}) => {
],
});
};
+
+/**
+ * Generates stacked mock graph data according to options
+ *
+ * @param {Object} panelOptions - Panel options as in YML.
+ * @param {Object} dataOptions
+ */
+export const stackedColumnGraphData = (panelOptions = {}, dataOptions = {}) => {
+ return {
+ ...timeSeriesGraphData(panelOptions, dataOptions),
+ type: panelTypes.STACKED_COLUMN,
+ };
+};
+
+/**
+ * Generates bar mock graph data according to options
+ *
+ * @param {Object} panelOptions - Panel options as in YML.
+ * @param {Object} dataOptions
+ */
+export const barGraphData = (panelOptions = {}, dataOptions = {}) => {
+ return {
+ ...timeSeriesGraphData(panelOptions, dataOptions),
+ type: panelTypes.BAR,
+ };
+};
diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js
index 28a7dd1af4f..aea8815fb10 100644
--- a/spec/frontend/monitoring/mock_data.js
+++ b/spec/frontend/monitoring/mock_data.js
@@ -245,51 +245,6 @@ export const metricsResult = [
},
];
-export const stackedColumnMockedData = {
- title: 'memories',
- type: 'stacked-column',
- x_label: 'x label',
- y_label: 'y label',
- metrics: [
- {
- label: 'memory_1024',
- unit: 'count',
- series_name: 'group 1',
- prometheus_endpoint_path:
- '/root/autodevops-deploy-6/-/environments/24/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024',
- metricId: 'NO_DB_metric_of_ages_1024',
- result: [
- {
- metric: {},
- values: [
- ['2020-01-30T12:00:00.000Z', '5'],
- ['2020-01-30T12:01:00.000Z', '10'],
- ['2020-01-30T12:02:00.000Z', '15'],
- ],
- },
- ],
- },
- {
- label: 'memory_1000',
- unit: 'count',
- series_name: 'group 2',
- prometheus_endpoint_path:
- '/root/autodevops-deploy-6/-/environments/24/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024',
- metricId: 'NO_DB_metric_of_ages_1000',
- result: [
- {
- metric: {},
- values: [
- ['2020-01-30T12:00:00.000Z', '20'],
- ['2020-01-30T12:01:00.000Z', '25'],
- ['2020-01-30T12:02:00.000Z', '30'],
- ],
- },
- ],
- },
- ],
-};
-
export const barMockData = {
title: 'SLA Trends - Primary Services',
type: 'bar',
diff --git a/spec/frontend/mr_popover/mr_popover_spec.js b/spec/frontend/mr_popover/mr_popover_spec.js
index 3f62dca4a57..094d1a6472c 100644
--- a/spec/frontend/mr_popover/mr_popover_spec.js
+++ b/spec/frontend/mr_popover/mr_popover_spec.js
@@ -61,7 +61,7 @@ describe('MR Popover', () => {
});
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.contains(CiIcon)).toBe(false);
+ expect(wrapper.find(CiIcon).exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/namespace_select_spec.js b/spec/frontend/namespace_select_spec.js
index 399fa950769..d6f3eb75cd9 100644
--- a/spec/frontend/namespace_select_spec.js
+++ b/spec/frontend/namespace_select_spec.js
@@ -1,56 +1,55 @@
-import $ from 'jquery';
import NamespaceSelect from '~/namespace_select';
+import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-describe('NamespaceSelect', () => {
- beforeEach(() => {
- jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {});
- });
+jest.mock('~/deprecated_jquery_dropdown');
- it('initializes glDropdown', () => {
+describe('NamespaceSelect', () => {
+ it('initializes deprecatedJQueryDropdown', () => {
const dropdown = document.createElement('div');
// eslint-disable-next-line no-new
new NamespaceSelect({ dropdown });
- expect($.fn.glDropdown).toHaveBeenCalled();
+ expect(initDeprecatedJQueryDropdown).toHaveBeenCalled();
});
describe('as input', () => {
- let glDropdownOptions;
+ let deprecatedJQueryDropdownOptions;
beforeEach(() => {
const dropdown = document.createElement('div');
// eslint-disable-next-line no-new
new NamespaceSelect({ dropdown });
- [[glDropdownOptions]] = $.fn.glDropdown.mock.calls;
+ [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls;
});
it('prevents click events', () => {
const dummyEvent = new Event('dummy');
jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {});
- glDropdownOptions.clicked({ e: dummyEvent });
+ // expect(foo).toContain('test');
+ deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent });
expect(dummyEvent.preventDefault).toHaveBeenCalled();
});
});
describe('as filter', () => {
- let glDropdownOptions;
+ let deprecatedJQueryDropdownOptions;
beforeEach(() => {
const dropdown = document.createElement('div');
dropdown.dataset.isFilter = 'true';
// eslint-disable-next-line no-new
new NamespaceSelect({ dropdown });
- [[glDropdownOptions]] = $.fn.glDropdown.mock.calls;
+ [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls;
});
it('does not prevent click events', () => {
const dummyEvent = new Event('dummy');
jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {});
- glDropdownOptions.clicked({ e: dummyEvent });
+ deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent });
expect(dummyEvent.preventDefault).not.toHaveBeenCalled();
});
@@ -58,7 +57,7 @@ describe('NamespaceSelect', () => {
it('sets URL of dropdown items', () => {
const dummyNamespace = { id: 'eal' };
- const itemUrl = glDropdownOptions.url(dummyNamespace);
+ const itemUrl = deprecatedJQueryDropdownOptions.url(dummyNamespace);
expect(itemUrl).toContain(`namespace_id=${dummyNamespace.id}`);
});
diff --git a/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap b/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap
index b1a718d58b5..13af29821d8 100644
--- a/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap
+++ b/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap
@@ -12,7 +12,7 @@ exports[`JumpToNextDiscussionButton matches the snapshot 1`] = `
data-track-property="click_next_unresolved_thread"
title="Jump to next unresolved thread"
>
- <icon-stub
+ <gl-icon-stub
name="comment-next"
size="16"
/>
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index dc68c4371aa..59fa7b372ed 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -267,15 +267,14 @@ describe('issue_comment_form component', () => {
});
describe('when clicking close/reopen button', () => {
- it('should disable button and show a loading spinner', done => {
+ it('should disable button and show a loading spinner', () => {
const toggleStateButton = wrapper.find('.js-action-button');
toggleStateButton.trigger('click');
- wrapper.vm.$nextTick(() => {
- expect(toggleStateButton.element.disabled).toEqual(true);
- expect(toggleStateButton.find('.js-loading-button-icon').exists()).toBe(true);
- done();
+ return wrapper.vm.$nextTick().then(() => {
+ expect(toggleStateButton.element.disabled).toEqual(true);
+ expect(toggleStateButton.props('loading')).toBe(true);
});
});
});
@@ -321,4 +320,33 @@ describe('issue_comment_form component', () => {
expect(wrapper.find('textarea').exists()).toBe(false);
});
});
+
+ describe('when issuable is open', () => {
+ beforeEach(() => {
+ setupStore(userDataMock, noteableDataMock);
+ });
+
+ it.each([['opened', 'warning'], ['reopened', 'warning']])(
+ 'when %i, it changes the variant of the btn to %i',
+ (a, expected) => {
+ store.state.noteableData.state = a;
+
+ mountComponent();
+
+ expect(wrapper.find('.js-action-button').props('variant')).toBe(expected);
+ },
+ );
+ });
+
+ describe('when issuable is not open', () => {
+ beforeEach(() => {
+ setupStore(userDataMock, noteableDataMock);
+
+ mountComponent();
+ });
+
+ it('should render the "default" variant of the button', () => {
+ expect(wrapper.find('.js-action-button').props('variant')).toBe('warning');
+ });
+ });
});
diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js
index 04535aa17c5..affd6c1d1d2 100644
--- a/spec/frontend/notes/components/discussion_counter_spec.js
+++ b/spec/frontend/notes/components/discussion_counter_spec.js
@@ -1,5 +1,6 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import notesModule from '~/notes/stores/modules';
import DiscussionCounter from '~/notes/components/discussion_counter.vue';
import { noteableDataMock, discussionMock, notesDataMock, userDataMock } from '../mock_data';
@@ -112,13 +113,13 @@ describe('DiscussionCounter component', () => {
updateStoreWithExpanded(true);
expect(wrapper.vm.allExpanded).toBe(true);
- expect(toggleAllButton.find({ name: 'angle-up' }).exists()).toBe(true);
+ expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up');
toggleAllButton.trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.allExpanded).toBe(false);
- expect(toggleAllButton.find({ name: 'angle-down' }).exists()).toBe(true);
+ expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down');
});
});
@@ -126,13 +127,13 @@ describe('DiscussionCounter component', () => {
updateStoreWithExpanded(false);
expect(wrapper.vm.allExpanded).toBe(false);
- expect(toggleAllButton.find({ name: 'angle-down' }).exists()).toBe(true);
+ expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down');
toggleAllButton.trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.allExpanded).toBe(true);
- expect(toggleAllButton.find({ name: 'angle-up' }).exists()).toBe(true);
+ expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up');
});
});
});
diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js
index 9a7896475e6..91ff796b9de 100644
--- a/spec/frontend/notes/components/discussion_filter_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_spec.js
@@ -150,7 +150,7 @@ describe('DiscussionFilter component', () => {
eventHub.$emit('MergeRequestTabChange', 'commit');
wrapper.vm.$nextTick(() => {
- expect(wrapper.isEmpty()).toBe(true);
+ expect(wrapper.html()).toBe('');
done();
});
});
diff --git a/spec/frontend/notes/components/discussion_resolve_button_spec.js b/spec/frontend/notes/components/discussion_resolve_button_spec.js
index c64e299efc3..41701e54dfa 100644
--- a/spec/frontend/notes/components/discussion_resolve_button_spec.js
+++ b/spec/frontend/notes/components/discussion_resolve_button_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
import resolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue';
const buttonTitle = 'Resolve discussion';
@@ -26,9 +27,9 @@ describe('resolveDiscussionButton', () => {
});
it('should emit a onClick event on button click', () => {
- const button = wrapper.find({ ref: 'button' });
+ const button = wrapper.find(GlButton);
- button.trigger('click');
+ button.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted()).toEqual({
@@ -38,7 +39,7 @@ describe('resolveDiscussionButton', () => {
});
it('should contain the provided button title', () => {
- const button = wrapper.find({ ref: 'button' });
+ const button = wrapper.find(GlButton);
expect(button.text()).toContain(buttonTitle);
});
@@ -51,9 +52,9 @@ describe('resolveDiscussionButton', () => {
},
});
- const button = wrapper.find({ ref: 'isResolvingIcon' });
+ const button = wrapper.find(GlButton);
- expect(button.exists()).toEqual(true);
+ expect(button.props('loading')).toEqual(true);
});
it('should only show a loading spinner while resolving', () => {
@@ -64,10 +65,10 @@ describe('resolveDiscussionButton', () => {
},
});
- const button = wrapper.find({ ref: 'isResolvingIcon' });
+ const button = wrapper.find(GlButton);
wrapper.vm.$nextTick(() => {
- expect(button.exists()).toEqual(false);
+ expect(button.props('loading')).toEqual(false);
});
});
});
diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js
index 97d1752726b..a79c3bbacb7 100644
--- a/spec/frontend/notes/components/note_actions_spec.js
+++ b/spec/frontend/notes/components/note_actions_spec.js
@@ -35,8 +35,12 @@ describe('noteActions', () => {
canEdit: true,
canAwardEmoji: true,
canReportAsAbuse: true,
+ isAuthor: true,
+ isContributor: false,
+ noteableType: 'MergeRequest',
noteId: '539',
noteUrl: `${TEST_HOST}/group/project/-/merge_requests/1#note_1`,
+ projectName: 'project',
reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`,
showReply: false,
};
@@ -60,15 +64,43 @@ describe('noteActions', () => {
wrapper = shallowMountNoteActions(props);
});
+ it('should render noteable author badge', () => {
+ expect(
+ wrapper
+ .findAll('.note-role')
+ .at(0)
+ .text()
+ .trim(),
+ ).toEqual('Author');
+ });
+
it('should render access level badge', () => {
expect(
wrapper
- .find('.note-role')
+ .findAll('.note-role')
+ .at(1)
.text()
.trim(),
).toEqual(props.accessLevel);
});
+ it('should render contributor badge', () => {
+ wrapper.setProps({
+ accessLevel: null,
+ isContributor: true,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(
+ wrapper
+ .findAll('.note-role')
+ .at(1)
+ .text()
+ .trim(),
+ ).toBe('Contributor');
+ });
+ });
+
it('should render emoji link', () => {
expect(wrapper.find('.js-add-award').exists()).toBe(true);
expect(wrapper.find('.js-add-award').attributes('data-position')).toBe('right');
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index fbfba2efb1d..c6034639a4a 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -330,6 +330,8 @@ describe('note_app', () => {
wrapper.vm.$parent.$el.dispatchEvent(toggleAwardEvent);
+ jest.advanceTimersByTime(2);
+
expect(toggleAwardAction).toHaveBeenCalledTimes(1);
const [, payload] = toggleAwardAction.mock.calls[0];
diff --git a/spec/frontend/notes/helpers.js b/spec/frontend/notes/helpers.js
index 3f349b40ba5..c8168a49a5b 100644
--- a/spec/frontend/notes/helpers.js
+++ b/spec/frontend/notes/helpers.js
@@ -1,4 +1,3 @@
-// eslint-disable-next-line import/prefer-default-export
export const resetStore = store => {
store.replaceState({
notes: [],
diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js
index 11c0bbfefc9..d203435e7bf 100644
--- a/spec/frontend/notes/mixins/discussion_navigation_spec.js
+++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js
@@ -194,13 +194,9 @@ describe('Discussion navigation mixin', () => {
});
it('expands discussion', () => {
- expect(expandDiscussion).toHaveBeenCalledWith(
- expect.anything(),
- {
- discussionId: expected,
- },
- undefined,
- );
+ expect(expandDiscussion).toHaveBeenCalledWith(expect.anything(), {
+ discussionId: expected,
+ });
});
it('scrolls to discussion', () => {
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index 6b8d0790669..4681f3aa429 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -3,6 +3,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import Api from '~/api';
import { deprecatedCreateFlash as Flash } from '~/flash';
import * as actions from '~/notes/stores/actions';
+import mutations from '~/notes/stores/mutations';
import * as mutationTypes from '~/notes/stores/mutation_types';
import * as notesConstants from '~/notes/constants';
import createStore from '~/notes/stores';
@@ -334,6 +335,9 @@ describe('Actions Notes Store', () => {
it('calls service with last fetched state', done => {
store
.dispatch('poll')
+ .then(() => {
+ jest.advanceTimersByTime(2);
+ })
.then(() => new Promise(resolve => requestAnimationFrame(resolve)))
.then(() => {
expect(store.state.lastFetchedAt).toBe('123456');
@@ -651,6 +655,26 @@ describe('Actions Notes Store', () => {
});
describe('updateOrCreateNotes', () => {
+ it('Prevents `fetchDiscussions` being called multiple times within time limit', () => {
+ jest.useFakeTimers();
+ const note = { id: 1234, type: notesConstants.DIFF_NOTE };
+ const getters = { notesById: {} };
+ state = { discussions: [note], notesData: { discussionsPath: '' } };
+ commit.mockImplementation((type, value) => {
+ if (type === mutationTypes.SET_FETCHING_DISCUSSIONS) {
+ mutations[type](state, value);
+ }
+ });
+
+ actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]);
+ actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]);
+
+ jest.runAllTimers();
+ actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]);
+
+ expect(dispatch).toHaveBeenCalledTimes(2);
+ });
+
it('Updates existing note', () => {
const note = { id: 1234 };
const getters = { notesById: { 1234: note } };
diff --git a/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap
deleted file mode 100644
index 172b8919673..00000000000
--- a/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap
+++ /dev/null
@@ -1,46 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Package code instruction multiline to match the snapshot 1`] = `
-<div>
- <pre
- class="js-instruction-pre"
- >
- this is some
-multiline text
- </pre>
-</div>
-`;
-
-exports[`Package code instruction single line to match the default snapshot 1`] = `
-<div
- class="input-group gl-mb-3"
->
- <input
- class="form-control monospace js-instruction-input"
- readonly="readonly"
- type="text"
- />
-
- <span
- class="input-group-append js-instruction-button"
- >
- <button
- class="btn input-group-text btn-secondary btn-md btn-default"
- data-clipboard-text="npm i @my-package"
- title="Copy npm install command"
- type="button"
- >
- <!---->
-
- <svg
- class="gl-icon s16"
- data-testid="copy-to-clipboard-icon"
- >
- <use
- href="#copy-to-clipboard"
- />
- </svg>
- </button>
- </span>
-</div>
-`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap
index 852292e084b..a1d08f032bc 100644
--- a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap
@@ -8,18 +8,12 @@ exports[`ConanInstallation renders all the messages 1`] = `
Installation
</h3>
- <h4
- class="gl-font-base"
- >
-
- Conan Command
-
- </h4>
-
<code-instruction-stub
copytext="Copy Conan Command"
instruction="foo/command"
+ label="Conan Command"
trackingaction="copy_conan_command"
+ trackinglabel="code_instruction"
/>
<h3
@@ -28,18 +22,12 @@ exports[`ConanInstallation renders all the messages 1`] = `
Registry setup
</h3>
- <h4
- class="gl-font-base"
- >
-
- Add Conan Remote
-
- </h4>
-
<code-instruction-stub
copytext="Copy Conan Setup Command"
instruction="foo/setup"
+ label="Add Conan Remote"
trackingaction="copy_conan_setup_command"
+ trackinglabel="code_instruction"
/>
<gl-sprintf-stub
diff --git a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap
index 28b7ca442eb..39469bf4fd0 100644
--- a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap
@@ -21,7 +21,7 @@ exports[`DependencyRow renders full dependency 1`] = `
</div>
<div
- class="table-section section-50 gl-display-flex justify-content-md-end"
+ class="table-section section-50 gl-display-flex gl-md-justify-content-end"
data-testid="version-pattern"
>
<span
diff --git a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap
index 10e54500797..6d22b372d41 100644
--- a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap
@@ -8,14 +8,6 @@ exports[`MavenInstallation renders all the messages 1`] = `
Installation
</h3>
- <h4
- class="gl-font-base"
- >
-
- Maven XML
-
- </h4>
-
<p>
<gl-sprintf-stub
message="Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block."
@@ -25,22 +17,18 @@ exports[`MavenInstallation renders all the messages 1`] = `
<code-instruction-stub
copytext="Copy Maven XML"
instruction="foo/xml"
+ label="Maven XML"
multiline="true"
trackingaction="copy_maven_xml"
+ trackinglabel="code_instruction"
/>
- <h4
- class="gl-font-base"
- >
-
- Maven Command
-
- </h4>
-
<code-instruction-stub
copytext="Copy Maven command"
instruction="foo/command"
+ label="Maven Command"
trackingaction="copy_maven_command"
+ trackinglabel="code_instruction"
/>
<h3
@@ -58,8 +46,10 @@ exports[`MavenInstallation renders all the messages 1`] = `
<code-instruction-stub
copytext="Copy Maven registry XML"
instruction="foo/setup"
+ label=""
multiline="true"
trackingaction="copy_maven_setup_xml"
+ trackinglabel="code_instruction"
/>
<gl-sprintf-stub
diff --git a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap
index 58a509e6847..b616751f75f 100644
--- a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap
@@ -8,28 +8,20 @@ exports[`NpmInstallation renders all the messages 1`] = `
Installation
</h3>
- <h4
- class="gl-font-base"
- >
- npm command
- </h4>
-
<code-instruction-stub
copytext="Copy npm command"
instruction="npm i @Test/package"
+ label="npm command"
trackingaction="copy_npm_install_command"
+ trackinglabel="code_instruction"
/>
- <h4
- class="gl-font-base"
- >
- yarn command
- </h4>
-
<code-instruction-stub
copytext="Copy yarn command"
instruction="yarn add @Test/package"
+ label="yarn command"
trackingaction="copy_yarn_install_command"
+ trackinglabel="code_instruction"
/>
<h3
@@ -38,28 +30,20 @@ exports[`NpmInstallation renders all the messages 1`] = `
Registry setup
</h3>
- <h4
- class="gl-font-base"
- >
- npm command
- </h4>
-
<code-instruction-stub
copytext="Copy npm setup command"
- instruction="echo @Test:registry=undefined >> .npmrc"
+ instruction="echo @Test:registry=undefined/ >> .npmrc"
+ label="npm command"
trackingaction="copy_npm_setup_command"
+ trackinglabel="code_instruction"
/>
- <h4
- class="gl-font-base"
- >
- yarn command
- </h4>
-
<code-instruction-stub
copytext="Copy yarn setup command"
- instruction="echo \\\\\\"@Test:registry\\\\\\" \\\\\\"undefined\\\\\\" >> .yarnrc"
+ instruction="echo \\\\\\"@Test:registry\\\\\\" \\\\\\"undefined/\\\\\\" >> .yarnrc"
+ label="yarn command"
trackingaction="copy_yarn_setup_command"
+ trackinglabel="code_instruction"
/>
<gl-sprintf-stub
diff --git a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap
index 67810290c62..84cf5e4db49 100644
--- a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap
@@ -8,18 +8,12 @@ exports[`NugetInstallation renders all the messages 1`] = `
Installation
</h3>
- <h4
- class="gl-font-base"
- >
-
- NuGet Command
-
- </h4>
-
<code-instruction-stub
copytext="Copy NuGet Command"
instruction="foo/command"
+ label="NuGet Command"
trackingaction="copy_nuget_install_command"
+ trackinglabel="code_instruction"
/>
<h3
@@ -28,18 +22,12 @@ exports[`NugetInstallation renders all the messages 1`] = `
Registry setup
</h3>
- <h4
- class="gl-font-base"
- >
-
- Add NuGet Source
-
- </h4>
-
<code-instruction-stub
copytext="Copy NuGet Setup Command"
instruction="foo/setup"
+ label="Add NuGet Source"
trackingaction="copy_nuget_setup_command"
+ trackinglabel="code_instruction"
/>
<gl-sprintf-stub
diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
index bdcd4a9e077..4d9e0af1545 100644
--- a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
@@ -2,171 +2,151 @@
exports[`PackageTitle renders with tags 1`] = `
<div
- class="gl-flex-direction-column"
+ class="gl-display-flex gl-justify-content-space-between gl-py-3"
+ data-qa-selector="package_title"
>
<div
- class="gl-display-flex"
+ class="gl-flex-direction-column"
>
- <!---->
-
<div
- class="gl-display-flex gl-flex-direction-column"
+ class="gl-display-flex"
>
- <h1
- class="gl-font-size-h1 gl-mt-3 gl-mb-2"
- >
-
- Test package
-
- </h1>
+ <!---->
<div
- class="gl-display-flex gl-align-items-center gl-text-gray-500"
+ class="gl-display-flex gl-flex-direction-column"
>
- <gl-icon-stub
- class="gl-mr-3"
- name="eye"
- size="16"
- />
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ data-testid="title"
+ >
+ Test package
+ </h1>
- <gl-sprintf-stub
- message="v%{version} published %{timeAgo}"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ >
+ <gl-icon-stub
+ class="gl-mr-3"
+ name="eye"
+ size="16"
+ />
+
+ <gl-sprintf-stub
+ message="v%{version} published %{timeAgo}"
+ />
+ </div>
</div>
</div>
- </div>
-
- <div
- class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mb-3"
- >
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <gl-icon-stub
- class="gl-text-gray-500 gl-mr-3"
- name="package"
- size="16"
- />
-
- <span
- class="gl-font-weight-bold"
- data-testid="package-type"
- >
- maven
- </span>
- </div>
<div
- class="gl-display-flex gl-align-items-center gl-mr-5"
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
>
- <package-tags-stub
- tagdisplaylimit="1"
- tags="[object Object],[object Object],[object Object],[object Object]"
- />
- </div>
-
- <!---->
-
- <!---->
-
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <gl-icon-stub
- class="gl-text-gray-500 gl-mr-3"
- name="disk"
- size="16"
- />
-
- <span
- class="gl-font-weight-bold"
- data-testid="package-size"
+ <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="maven"
+ />
+ </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="300 bytes"
+ />
+ </div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
>
- 300 bytes
- </span>
+ <package-tags-stub
+ hidelabel="true"
+ tagdisplaylimit="2"
+ tags="[object Object],[object Object],[object Object],[object Object]"
+ />
+ </div>
</div>
</div>
+
+ <!---->
</div>
`;
exports[`PackageTitle renders without tags 1`] = `
<div
- class="gl-flex-direction-column"
+ class="gl-display-flex gl-justify-content-space-between gl-py-3"
+ data-qa-selector="package_title"
>
<div
- class="gl-display-flex"
+ class="gl-flex-direction-column"
>
- <!---->
-
<div
- class="gl-display-flex gl-flex-direction-column"
+ class="gl-display-flex"
>
- <h1
- class="gl-font-size-h1 gl-mt-3 gl-mb-2"
- >
-
- Test package
-
- </h1>
+ <!---->
<div
- class="gl-display-flex gl-align-items-center gl-text-gray-500"
+ class="gl-display-flex gl-flex-direction-column"
>
- <gl-icon-stub
- class="gl-mr-3"
- name="eye"
- size="16"
- />
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ data-testid="title"
+ >
+ Test package
+ </h1>
- <gl-sprintf-stub
- message="v%{version} published %{timeAgo}"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ >
+ <gl-icon-stub
+ class="gl-mr-3"
+ name="eye"
+ size="16"
+ />
+
+ <gl-sprintf-stub
+ message="v%{version} published %{timeAgo}"
+ />
+ </div>
</div>
</div>
- </div>
-
- <div
- class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mb-3"
- >
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <gl-icon-stub
- class="gl-text-gray-500 gl-mr-3"
- name="package"
- size="16"
- />
-
- <span
- class="gl-font-weight-bold"
- data-testid="package-type"
- >
- maven
- </span>
- </div>
-
- <!---->
-
- <!---->
-
- <!---->
<div
- class="gl-display-flex gl-align-items-center gl-mr-5"
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
>
- <gl-icon-stub
- class="gl-text-gray-500 gl-mr-3"
- name="disk"
- size="16"
- />
-
- <span
- class="gl-font-weight-bold"
- data-testid="package-size"
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
>
- 300 bytes
- </span>
+ <metadata-item-stub
+ data-testid="package-type"
+ icon="package"
+ link=""
+ size="s"
+ text="maven"
+ />
+ </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="300 bytes"
+ />
+ </div>
</div>
</div>
+
+ <!---->
</div>
`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap
index 5c1e74d73af..2a588f99c1d 100644
--- a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap
@@ -8,19 +8,13 @@ exports[`PypiInstallation renders all the messages 1`] = `
Installation
</h3>
- <h4
- class="gl-font-base"
- >
-
- Pip Command
-
- </h4>
-
<code-instruction-stub
copytext="Copy Pip command"
data-testid="pip-command"
instruction="pip install"
+ label="Pip Command"
trackingaction="copy_pip_install_command"
+ trackinglabel="code_instruction"
/>
<h3
@@ -39,8 +33,10 @@ exports[`PypiInstallation renders all the messages 1`] = `
copytext="Copy .pypirc content"
data-testid="pypi-setup-content"
instruction="python setup"
+ label=""
multiline="true"
trackingaction="copy_pypi_setup_command"
+ trackinglabel="code_instruction"
/>
<gl-sprintf-stub
diff --git a/spec/frontend/packages/details/components/additional_metadata_spec.js b/spec/frontend/packages/details/components/additional_metadata_spec.js
index b2337b86740..111e4205abb 100644
--- a/spec/frontend/packages/details/components/additional_metadata_spec.js
+++ b/spec/frontend/packages/details/components/additional_metadata_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { GlLink, GlSprintf } from '@gitlab/ui';
-import DetailsRow from '~/registry/shared/components/details_row.vue';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
import component from '~/packages/details/components/additional_metadata.vue';
import { mavenPackage, conanPackage, nugetPackage, npmPackage } from '../../mock_data';
diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js
index f535f3f5744..e82c74e56e5 100644
--- a/spec/frontend/packages/details/components/app_spec.js
+++ b/spec/frontend/packages/details/components/app_spec.js
@@ -34,12 +34,15 @@ describe('PackagesApp', () => {
let wrapper;
let store;
const fetchPackageVersions = jest.fn();
+ const deletePackage = jest.fn();
+ const defaultProjectName = 'bar';
+ const { location } = window;
function createComponent({
packageEntity = mavenPackage,
packageFiles = mavenFiles,
isLoading = false,
- oneColumnView = false,
+ projectName = defaultProjectName,
} = {}) {
store = new Vuex.Store({
state: {
@@ -47,14 +50,15 @@ describe('PackagesApp', () => {
packageEntity,
packageFiles,
canDelete: true,
- destroyPath: 'destroy-package-path',
emptySvgPath: 'empty-illustration',
npmPath: 'foo',
npmHelpPath: 'foo',
- projectName: 'bar',
- oneColumnView,
+ projectName,
+ projectListUrl: 'project_url',
+ groupListUrl: 'group_url',
},
actions: {
+ deletePackage,
fetchPackageVersions,
},
getters,
@@ -65,6 +69,8 @@ describe('PackagesApp', () => {
store,
stubs: {
...stubChildren(PackagesApp),
+ PackageTitle: false,
+ TitleArea: false,
GlButton: false,
GlModal: false,
GlTab: false,
@@ -93,8 +99,14 @@ describe('PackagesApp', () => {
const findAdditionalMetadata = () => wrapper.find(AdditionalMetadata);
const findInstallationCommands = () => wrapper.find(InstallationCommands);
+ beforeEach(() => {
+ delete window.location;
+ window.location = { replace: jest.fn() };
+ });
+
afterEach(() => {
wrapper.destroy();
+ window.location = location;
});
it('renders the app and displays the package title', () => {
@@ -238,44 +250,94 @@ describe('PackagesApp', () => {
});
});
- describe('tracking', () => {
- let eventSpy;
- let utilSpy;
- const category = 'foo';
+ describe('tracking and delete', () => {
+ const doDelete = async () => {
+ deleteButton().trigger('click');
+ await wrapper.vm.$nextTick();
+ modalDeleteButton().trigger('click');
+ };
+
+ describe('delete', () => {
+ const originalReferrer = document.referrer;
+ const setReferrer = (value = defaultProjectName) => {
+ Object.defineProperty(document, 'referrer', {
+ value,
+ configurable: true,
+ });
+ };
+
+ afterEach(() => {
+ Object.defineProperty(document, 'referrer', {
+ value: originalReferrer,
+ configurable: true,
+ });
+ });
- beforeEach(() => {
- eventSpy = jest.spyOn(Tracking, 'event');
- utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category);
- });
+ it('calls the proper vuex action', async () => {
+ createComponent({ packageEntity: npmPackage });
+ await doDelete();
+ expect(deletePackage).toHaveBeenCalled();
+ });
- it('tracking category calls packageTypeToTrackCategory', () => {
- createComponent({ packageEntity: conanPackage });
- expect(wrapper.vm.tracking.category).toBe(category);
- expect(utilSpy).toHaveBeenCalledWith('conan');
+ it('when referrer contains project name calls window.replace with project url', async () => {
+ setReferrer();
+ deletePackage.mockResolvedValue();
+ createComponent({ packageEntity: npmPackage });
+ await doDelete();
+ await deletePackage();
+ expect(window.location.replace).toHaveBeenCalledWith(
+ 'project_url?showSuccessDeleteAlert=true',
+ );
+ });
+
+ it('when referrer does not contain project name calls window.replace with group url', async () => {
+ setReferrer('baz');
+ deletePackage.mockResolvedValue();
+ createComponent({ packageEntity: npmPackage });
+ await doDelete();
+ await deletePackage();
+ expect(window.location.replace).toHaveBeenCalledWith(
+ 'group_url?showSuccessDeleteAlert=true',
+ );
+ });
});
- it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => {
- createComponent({ packageEntity: conanPackage });
- deleteButton().trigger('click');
- return wrapper.vm.$nextTick().then(() => {
- modalDeleteButton().trigger('click');
+ describe('tracking', () => {
+ let eventSpy;
+ let utilSpy;
+ const category = 'foo';
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category);
+ });
+
+ it('tracking category calls packageTypeToTrackCategory', () => {
+ createComponent({ packageEntity: conanPackage });
+ expect(wrapper.vm.tracking.category).toBe(category);
+ expect(utilSpy).toHaveBeenCalledWith('conan');
+ });
+
+ it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, async () => {
+ createComponent({ packageEntity: npmPackage });
+ await doDelete();
expect(eventSpy).toHaveBeenCalledWith(
category,
TrackingActions.DELETE_PACKAGE,
expect.any(Object),
);
});
- });
- it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => {
- createComponent({ packageEntity: conanPackage });
+ it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => {
+ createComponent({ packageEntity: conanPackage });
- firstFileDownloadLink().vm.$emit('click');
- expect(eventSpy).toHaveBeenCalledWith(
- category,
- TrackingActions.PULL_PACKAGE,
- expect.any(Object),
- );
+ firstFileDownloadLink().vm.$emit('click');
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ TrackingActions.PULL_PACKAGE,
+ expect.any(Object),
+ );
+ });
});
});
});
diff --git a/spec/frontend/packages/details/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js
index 7679d721391..c13981fbb87 100644
--- a/spec/frontend/packages/details/components/composer_installation_spec.js
+++ b/spec/frontend/packages/details/components/composer_installation_spec.js
@@ -4,7 +4,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui';
import { registryUrl as composerHelpPath } from 'jest/packages/details/mock_data';
import { composerPackage as packageEntity } from 'jest/packages/mock_data';
import ComposerInstallation from '~/packages/details/components/composer_installation.vue';
-import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+
import { TrackingActions } from '~/packages/details/constants';
const localVue = createLocalVue();
@@ -27,9 +27,8 @@ describe('ComposerInstallation', () => {
},
});
- const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
- const findRegistryIncludeTitle = () => wrapper.find('[data-testid="registry-include-title"]');
- const findPackageIncludeTitle = () => wrapper.find('[data-testid="package-include-title"]');
+ const findRegistryInclude = () => wrapper.find('[data-testid="registry-include"]');
+ const findPackageInclude = () => wrapper.find('[data-testid="package-include"]');
const findHelpText = () => wrapper.find('[data-testid="help-text"]');
const findHelpLink = () => wrapper.find(GlLink);
@@ -53,7 +52,7 @@ describe('ComposerInstallation', () => {
describe('registry include command', () => {
it('uses code_instructions', () => {
- const registryIncludeCommand = findCodeInstructions().at(0);
+ const registryIncludeCommand = findRegistryInclude();
expect(registryIncludeCommand.exists()).toBe(true);
expect(registryIncludeCommand.props()).toMatchObject({
instruction: composerRegistryIncludeStr,
@@ -63,13 +62,13 @@ describe('ComposerInstallation', () => {
});
it('has the correct title', () => {
- expect(findRegistryIncludeTitle().text()).toBe('composer.json registry include');
+ expect(findRegistryInclude().props('label')).toBe('composer.json registry include');
});
});
describe('package include command', () => {
it('uses code_instructions', () => {
- const registryIncludeCommand = findCodeInstructions().at(1);
+ const registryIncludeCommand = findPackageInclude();
expect(registryIncludeCommand.exists()).toBe(true);
expect(registryIncludeCommand.props()).toMatchObject({
instruction: composerPackageIncludeStr,
@@ -79,7 +78,7 @@ describe('ComposerInstallation', () => {
});
it('has the correct title', () => {
- expect(findPackageIncludeTitle().text()).toBe('composer.json require package include');
+ expect(findPackageInclude().props('label')).toBe('composer.json require package include');
});
it('has the correct help text', () => {
diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js
index 5b31e38dad5..c79d1bb50dd 100644
--- a/spec/frontend/packages/details/components/conan_installation_spec.js
+++ b/spec/frontend/packages/details/components/conan_installation_spec.js
@@ -1,7 +1,7 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import ConanInstallation from '~/packages/details/components/conan_installation.vue';
-import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
import { conanPackage as packageEntity } from '../../mock_data';
import { registryUrl as conanPath } from '../mock_data';
diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js
index 5d0007294b6..f301a03a7f3 100644
--- a/spec/frontend/packages/details/components/maven_installation_spec.js
+++ b/spec/frontend/packages/details/components/maven_installation_spec.js
@@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import { registryUrl as mavenPath } from 'jest/packages/details/mock_data';
import { mavenPackage as packageEntity } from 'jest/packages/mock_data';
import MavenInstallation from '~/packages/details/components/maven_installation.vue';
-import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions } from '~/packages/details/constants';
const localVue = createLocalVue();
diff --git a/spec/frontend/packages/details/components/npm_installation_spec.js b/spec/frontend/packages/details/components/npm_installation_spec.js
index f47bac57a66..4223a05453c 100644
--- a/spec/frontend/packages/details/components/npm_installation_spec.js
+++ b/spec/frontend/packages/details/components/npm_installation_spec.js
@@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import { npmPackage as packageEntity } from 'jest/packages/mock_data';
import { registryUrl as nugetPath } from 'jest/packages/details/mock_data';
import NpmInstallation from '~/packages/details/components/npm_installation.vue';
-import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions } from '~/packages/details/constants';
import { npmInstallationCommand, npmSetupCommand } from '~/packages/details/store/getters';
@@ -78,7 +78,7 @@ describe('NpmInstallation', () => {
.at(2)
.props(),
).toMatchObject({
- instruction: 'echo @Test:registry=undefined >> .npmrc',
+ instruction: 'echo @Test:registry=undefined/ >> .npmrc',
multiline: false,
trackingAction: TrackingActions.COPY_NPM_SETUP_COMMAND,
});
@@ -90,7 +90,7 @@ describe('NpmInstallation', () => {
.at(3)
.props(),
).toMatchObject({
- instruction: 'echo \\"@Test:registry\\" \\"undefined\\" >> .yarnrc',
+ instruction: 'echo \\"@Test:registry\\" \\"undefined/\\" >> .yarnrc',
multiline: false,
trackingAction: TrackingActions.COPY_YARN_SETUP_COMMAND,
});
diff --git a/spec/frontend/packages/details/components/nuget_installation_spec.js b/spec/frontend/packages/details/components/nuget_installation_spec.js
index a23bf9a18a1..b381d131e94 100644
--- a/spec/frontend/packages/details/components/nuget_installation_spec.js
+++ b/spec/frontend/packages/details/components/nuget_installation_spec.js
@@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nugetPackage as packageEntity } from 'jest/packages/mock_data';
import { registryUrl as nugetPath } from 'jest/packages/details/mock_data';
import NugetInstallation from '~/packages/details/components/nuget_installation.vue';
-import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions } from '~/packages/details/constants';
const localVue = createLocalVue();
diff --git a/spec/frontend/packages/details/components/package_history_spec.js b/spec/frontend/packages/details/components/package_history_spec.js
index e293e119585..f745a457b0a 100644
--- a/spec/frontend/packages/details/components/package_history_spec.js
+++ b/spec/frontend/packages/details/components/package_history_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { GlLink, GlSprintf } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import component from '~/packages/details/components/package_history.vue';
import { mavenPackage, mockPipelineInfo } from '../../mock_data';
@@ -16,7 +17,10 @@ describe('Package History', () => {
wrapper = shallowMount(component, {
propsData: { ...defaultProps, ...props },
stubs: {
- HistoryElement: '<div data-testid="history-element"><slot></slot></div>',
+ HistoryItem: {
+ props: HistoryItem.props,
+ template: '<div data-testid="history-element"><slot></slot></div>',
+ },
GlSprintf,
},
});
diff --git a/spec/frontend/packages/details/components/package_title_spec.js b/spec/frontend/packages/details/components/package_title_spec.js
index a30dc4b8aba..d0ed78418af 100644
--- a/spec/frontend/packages/details/components/package_title_spec.js
+++ b/spec/frontend/packages/details/components/package_title_spec.js
@@ -2,6 +2,7 @@ import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import PackageTitle from '~/packages/details/components/package_title.vue';
import PackageTags from '~/packages/shared/components/package_tags.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import {
conanPackage,
mavenFiles,
@@ -39,10 +40,14 @@ describe('PackageTitle', () => {
wrapper = shallowMount(PackageTitle, {
localVue,
store,
+ stubs: {
+ TitleArea,
+ },
});
+ return wrapper.vm.$nextTick();
}
- const packageIcon = () => wrapper.find('[data-testid="package-icon"]');
+ const findTitleArea = () => wrapper.find(TitleArea);
const packageType = () => wrapper.find('[data-testid="package-type"]');
const packageSize = () => wrapper.find('[data-testid="package-size"]');
const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]');
@@ -54,72 +59,74 @@ describe('PackageTitle', () => {
});
describe('renders', () => {
- it('without tags', () => {
- createComponent();
+ it('without tags', async () => {
+ await createComponent();
expect(wrapper.element).toMatchSnapshot();
});
- it('with tags', () => {
- createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } });
+ it('with tags', async () => {
+ await createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } });
expect(wrapper.element).toMatchSnapshot();
});
});
- describe('package icon', () => {
- const fakeSrc = 'a-fake-src';
-
- it('shows an icon when provided one from vuex', () => {
- createComponent({ icon: fakeSrc });
+ describe('package title', () => {
+ it('is correctly bound', async () => {
+ await createComponent();
- expect(packageIcon().exists()).toBe(true);
+ expect(findTitleArea().props('title')).toBe('Test package');
});
+ });
- it('has the correct src attribute', () => {
- createComponent({ icon: fakeSrc });
+ describe('package icon', () => {
+ const fakeSrc = 'a-fake-src';
+
+ it('binds an icon when provided one from vuex', async () => {
+ await createComponent({ icon: fakeSrc });
- expect(packageIcon().props('src')).toBe(fakeSrc);
+ expect(findTitleArea().props('avatar')).toBe(fakeSrc);
});
- it('does not show an icon when not provided one', () => {
- createComponent();
+ it('do not binds an icon when not provided one', async () => {
+ await createComponent();
- expect(packageIcon().exists()).toBe(false);
+ expect(findTitleArea().props('avatar')).toBe(null);
});
});
describe.each`
- packageEntity | expectedResult
+ packageEntity | text
${conanPackage} | ${'conan'}
${mavenPackage} | ${'maven'}
${npmPackage} | ${'npm'}
${nugetPackage} | ${'nuget'}
- `(`package type`, ({ packageEntity, expectedResult }) => {
+ `(`package type`, ({ packageEntity, text }) => {
beforeEach(() => createComponent({ packageEntity }));
- it(`${packageEntity.package_type} should render from Vuex getters ${expectedResult}`, () => {
- expect(packageType().text()).toBe(expectedResult);
+ it(`${packageEntity.package_type} should render from Vuex getters ${text}`, () => {
+ expect(packageType().props()).toEqual(expect.objectContaining({ text, icon: 'package' }));
});
});
describe('calculates the package size', () => {
- it('correctly calulates when there is only 1 file', () => {
- createComponent({ packageEntity: npmPackage, packageFiles: npmFiles });
+ it('correctly calculates when there is only 1 file', async () => {
+ await createComponent({ packageEntity: npmPackage, packageFiles: npmFiles });
- expect(packageSize().text()).toBe('200 bytes');
+ expect(packageSize().props()).toMatchObject({ text: '200 bytes', icon: 'disk' });
});
- it('correctly calulates when there are multiple files', () => {
- createComponent();
+ it('correctly calulates when there are multiple files', async () => {
+ await createComponent();
- expect(packageSize().text()).toBe('300 bytes');
+ expect(packageSize().props('text')).toBe('300 bytes');
});
});
describe('package tags', () => {
- it('displays the package-tags component when the package has tags', () => {
- createComponent({
+ it('displays the package-tags component when the package has tags', async () => {
+ await createComponent({
packageEntity: {
...npmPackage,
tags: mockTags,
@@ -129,40 +136,44 @@ describe('PackageTitle', () => {
expect(packageTags().exists()).toBe(true);
});
- it('does not display the package-tags component when there are no tags', () => {
- createComponent();
+ it('does not display the package-tags component when there are no tags', async () => {
+ await createComponent();
expect(packageTags().exists()).toBe(false);
});
});
describe('package ref', () => {
- it('does not display the ref if missing', () => {
- createComponent();
+ it('does not display the ref if missing', async () => {
+ await createComponent();
expect(packageRef().exists()).toBe(false);
});
- it('correctly shows the package ref if there is one', () => {
- createComponent({ packageEntity: npmPackage });
-
- expect(packageRef().contains('gl-icon-stub')).toBe(true);
- expect(packageRef().text()).toBe(npmPackage.pipeline.ref);
+ it('correctly shows the package ref if there is one', async () => {
+ await createComponent({ packageEntity: npmPackage });
+ expect(packageRef().props()).toMatchObject({
+ text: npmPackage.pipeline.ref,
+ icon: 'branch',
+ });
});
});
describe('pipeline project', () => {
- it('does not display the project if missing', () => {
- createComponent();
+ it('does not display the project if missing', async () => {
+ await createComponent();
expect(pipelineProject().exists()).toBe(false);
});
- it('correctly shows the pipeline project if there is one', () => {
- createComponent({ packageEntity: npmPackage });
+ it('correctly shows the pipeline project if there is one', async () => {
+ await createComponent({ packageEntity: npmPackage });
- expect(pipelineProject().text()).toBe(npmPackage.pipeline.project.name);
- expect(pipelineProject().attributes('href')).toBe(npmPackage.pipeline.project.web_url);
+ expect(pipelineProject().props()).toMatchObject({
+ text: npmPackage.pipeline.project.name,
+ icon: 'review-list',
+ link: npmPackage.pipeline.project.web_url,
+ });
});
});
});
diff --git a/spec/frontend/packages/details/store/actions_spec.js b/spec/frontend/packages/details/store/actions_spec.js
index 6dfb2b63f85..70f87d18bcb 100644
--- a/spec/frontend/packages/details/store/actions_spec.js
+++ b/spec/frontend/packages/details/store/actions_spec.js
@@ -1,9 +1,10 @@
import testAction from 'helpers/vuex_action_helper';
import Api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
-import fetchPackageVersions from '~/packages/details/store/actions';
+import { fetchPackageVersions, deletePackage } from '~/packages/details/store/actions';
import * as types from '~/packages/details/store/mutation_types';
import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages/details/constants';
+import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants';
import { npmPackage as packageEntity } from '../../mock_data';
jest.mock('~/flash.js');
@@ -73,4 +74,25 @@ describe('Actions Package details store', () => {
);
});
});
+
+ describe('deletePackage', () => {
+ it('should call Api.deleteProjectPackage', done => {
+ Api.deleteProjectPackage = jest.fn().mockResolvedValue();
+ testAction(deletePackage, undefined, { packageEntity }, [], [], () => {
+ expect(Api.deleteProjectPackage).toHaveBeenCalledWith(
+ packageEntity.project_id,
+ packageEntity.id,
+ );
+ done();
+ });
+ });
+ it('should create flash on API error', done => {
+ Api.deleteProjectPackage = jest.fn().mockRejectedValue();
+
+ testAction(deletePackage, undefined, { packageEntity }, [], [], () => {
+ expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE);
+ done();
+ });
+ });
+ });
});
diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js
index 307976d4124..0e95ee4cfd3 100644
--- a/spec/frontend/packages/details/store/getters_spec.js
+++ b/spec/frontend/packages/details/store/getters_spec.js
@@ -62,9 +62,9 @@ describe('Getters PackageDetails Store', () => {
const mavenSetupXmlBlock = generateMavenSetupXml();
const npmInstallStr = `npm i ${npmPackage.name}`;
- const npmSetupStr = `echo @Test:registry=${registryUrl} >> .npmrc`;
+ const npmSetupStr = `echo @Test:registry=${registryUrl}/ >> .npmrc`;
const yarnInstallStr = `yarn add ${npmPackage.name}`;
- const yarnSetupStr = `echo \\"@Test:registry\\" \\"${registryUrl}\\" >> .yarnrc`;
+ const yarnSetupStr = `echo \\"@Test:registry\\" \\"${registryUrl}/\\" >> .yarnrc`;
const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`;
const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName <your_username> -Password <your_token>`;
diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
index 2b7a4c83bed..6ff9376565a 100644
--- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
+++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
@@ -444,7 +444,7 @@ exports[`packages_list_app renders 1`] = `
</template>
<template>
<div
- class="d-flex align-self-center ml-md-auto py-1 py-md-0"
+ class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
>
<package-filter-stub
class="mr-1"
diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js
index 31bab3886c1..19ff4290f50 100644
--- a/spec/frontend/packages/list/components/packages_list_app_spec.js
+++ b/spec/frontend/packages/list/components/packages_list_app_spec.js
@@ -1,7 +1,14 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlEmptyState, GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui';
+import * as commonUtils from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
import PackageListApp from '~/packages/list/components/packages_list_app.vue';
+import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
+
+jest.mock('~/lib/utils/common_utils');
+jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -145,4 +152,46 @@ describe('packages_list_app', () => {
);
});
});
+
+ describe('delete alert handling', () => {
+ const { location } = window.location;
+ const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`;
+
+ beforeEach(() => {
+ createStore('foo');
+ jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {});
+ delete window.location;
+ window.location = {
+ href: `foo_bar_baz${search}`,
+ search,
+ };
+ });
+
+ afterEach(() => {
+ window.location = location;
+ });
+
+ it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
+ mountComponent();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: DELETE_PACKAGE_SUCCESS_MESSAGE,
+ type: 'notice',
+ });
+ });
+
+ it('calls historyReplaceState with a clean url', () => {
+ mountComponent();
+
+ expect(commonUtils.historyReplaceState).toHaveBeenCalledWith('foo_bar_baz');
+ });
+
+ it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
+ window.location.search = '';
+ mountComponent();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(commonUtils.historyReplaceState).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/packages/list/components/packages_list_spec.js b/spec/frontend/packages/list/components/packages_list_spec.js
index a90d5056212..f981cc2851a 100644
--- a/spec/frontend/packages/list/components/packages_list_spec.js
+++ b/spec/frontend/packages/list/components/packages_list_spec.js
@@ -18,13 +18,12 @@ describe('packages_list', () => {
let wrapper;
let store;
- const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' };
const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' };
const findPackagesListLoader = () => wrapper.find(PackagesListLoader);
const findPackageListPagination = () => wrapper.find(GlPagination);
const findPackageListDeleteModal = () => wrapper.find(GlModal);
- const findEmptySlot = () => wrapper.find({ name: 'empty-slot-stub' });
+ const findEmptySlot = () => wrapper.find(EmptySlotStub);
const findPackagesListRow = () => wrapper.find(PackagesListRow);
const createStore = (isGroupPage, packages, isLoading) => {
@@ -67,7 +66,6 @@ describe('packages_list', () => {
stubs: {
...stubChildren(PackagesList),
GlTable,
- GlSortingItem,
GlModal,
},
...options,
diff --git a/spec/frontend/packages/list/components/packages_sort_spec.js b/spec/frontend/packages/list/components/packages_sort_spec.js
index ff3e8e19413..5c4794d8f63 100644
--- a/spec/frontend/packages/list/components/packages_sort_spec.js
+++ b/spec/frontend/packages/list/components/packages_sort_spec.js
@@ -1,5 +1,5 @@
import Vuex from 'vuex';
-import { GlSorting } from '@gitlab/ui';
+import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import stubChildren from 'helpers/stub_children';
import PackagesSort from '~/packages/list/components/packages_sort.vue';
@@ -13,8 +13,6 @@ describe('packages_sort', () => {
let sorting;
let sortingItems;
- const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' };
-
const findPackageListSorting = () => wrapper.find(GlSorting);
const findSortingItems = () => wrapper.findAll(GlSortingItem);
diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages/list/stores/actions_spec.js
index faa629cc01f..cf205ecbac4 100644
--- a/spec/frontend/packages/list/stores/actions_spec.js
+++ b/spec/frontend/packages/list/stores/actions_spec.js
@@ -5,7 +5,8 @@ import Api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import * as actions from '~/packages/list/stores/actions';
import * as types from '~/packages/list/stores/mutation_types';
-import { MISSING_DELETE_PATH_ERROR, DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/list/constants';
+import { MISSING_DELETE_PATH_ERROR } from '~/packages/list/constants';
+import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants';
jest.mock('~/flash.js');
jest.mock('~/api.js');
diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js
index 86205b0744c..b95d06428ff 100644
--- a/spec/frontend/packages/mock_data.js
+++ b/spec/frontend/packages/mock_data.js
@@ -30,6 +30,7 @@ export const mavenPackage = {
name: 'Test package',
package_type: 'maven',
project_path: 'foo/bar/baz',
+ projectPathName: 'foo/bar/baz',
project_id: 1,
updated_at: '2015-12-10',
version: '1.0.0',
@@ -59,6 +60,7 @@ export const npmPackage = {
name: '@Test/package',
package_type: 'npm',
project_path: 'foo/bar/baz',
+ projectPathName: 'foo/bar/baz',
project_id: 1,
updated_at: '2015-12-10',
version: '',
@@ -86,6 +88,7 @@ export const conanPackage = {
id: 3,
name: 'conan-package',
project_path: 'foo/bar/baz',
+ projectPathName: 'foo/bar/baz',
package_files: [],
package_type: 'conan',
project_id: 1,
diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
index eab8d7b67cc..6aaefed92d0 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
@@ -2,99 +2,143 @@
exports[`packages_list_row renders 1`] = `
<div
- class="gl-responsive-table-row"
- data-qa-selector="packages-row"
+ class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1 gl-border-t-transparent gl-border-b-gray-100"
+ data-qa-selector="package_row"
>
<div
- class="table-section section-50 d-flex flex-md-column justify-content-between flex-wrap"
+ class="gl-display-flex gl-align-items-center gl-py-5"
>
- <div
- class="d-flex align-items-center mr-2"
- >
- <gl-link-stub
- class="text-dark font-weight-bold mb-md-1"
- data-qa-selector="package_link"
- href="foo"
- >
-
- Test package
-
- </gl-link-stub>
-
- <!---->
- </div>
+ <!---->
<div
- class="d-flex text-secondary text-truncate mt-md-2"
+ class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-fill-1"
>
- <span>
- 1.0.0
- </span>
-
- <!---->
-
<div
- class="d-flex align-items-center"
+ class="gl-display-flex gl-flex-direction-column gl-justify-content-space-between gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"
>
- <gl-icon-stub
- class="text-secondary ml-2 mr-1"
- name="review-list"
- size="16"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0"
+ >
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"
+ >
+ <gl-link-stub
+ class="gl-text-body gl-min-w-0"
+ data-qa-selector="package_link"
+ href="foo"
+ >
+ <gl-truncate-stub
+ position="end"
+ text="Test package"
+ />
+ </gl-link-stub>
+
+ <!---->
+ </div>
+
+ <!---->
+ </div>
- <gl-link-stub
- class="text-secondary"
- data-testid="packages-row-project"
- href="/foo/bar/baz"
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1 gl-min-h-6 gl-min-w-0 gl-flex-fill-1"
>
-
- </gl-link-stub>
+ <div
+ class="gl-display-flex"
+ >
+ <span>
+ 1.0.0
+ </span>
+
+ <!---->
+
+ <div
+ class="gl-display-flex gl-align-items-center"
+ >
+ <gl-icon-stub
+ class="gl-ml-3 gl-mr-2 gl-min-w-0"
+ name="review-list"
+ size="16"
+ />
+
+ <gl-link-stub
+ class="gl-text-body gl-min-w-0"
+ data-testid="packages-row-project"
+ href="/foo/bar/baz"
+ >
+ <gl-truncate-stub
+ position="end"
+ text="foo/bar/baz"
+ />
+ </gl-link-stub>
+ </div>
+
+ <div
+ class="d-flex align-items-center"
+ data-testid="package-type"
+ >
+ <gl-icon-stub
+ class="gl-ml-3 gl-mr-2"
+ name="package"
+ size="16"
+ />
+
+ <span>
+ Maven
+ </span>
+ </div>
+ </div>
+ </div>
</div>
<div
- class="d-flex align-items-center"
- data-testid="package-type"
+ class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0"
>
- <gl-icon-stub
- class="text-secondary ml-2 mr-1"
- name="package"
- size="16"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6"
+ >
+ <publish-method-stub
+ packageentity="[object Object]"
+ />
+ </div>
- <span>
- Maven
- </span>
+ <div
+ class="gl-display-flex gl-align-items-center gl-mt-1 gl-min-h-6"
+ >
+ <span>
+ <gl-sprintf-stub
+ message="Created %{timestamp}"
+ />
+ </span>
+ </div>
</div>
</div>
- </div>
-
- <div
- class="table-section d-flex flex-md-column justify-content-between align-items-md-end section-40"
- >
- <publish-method-stub
- packageentity="[object Object]"
- />
<div
- class="text-secondary order-0 order-md-1 mt-md-2"
+ class="gl-w-9 gl-display-none gl-display-sm-flex gl-justify-content-end gl-pr-1"
>
- <gl-sprintf-stub
- message="Created %{timestamp}"
+ <gl-button-stub
+ aria-label="Remove package"
+ category="primary"
+ data-testid="action-delete"
+ icon="remove"
+ size="medium"
+ title="Remove package"
+ variant="danger"
/>
</div>
</div>
<div
- class="table-section section-10 d-flex justify-content-end"
+ class="gl-display-flex"
>
- <gl-button-stub
- aria-label="Remove package"
- category="primary"
- data-testid="action-delete"
- icon="remove"
- size="medium"
- title="Remove package"
- variant="danger"
+ <div
+ class="gl-w-7"
+ />
+
+ <!---->
+
+ <div
+ class="gl-w-9"
/>
</div>
</div>
diff --git a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
index 5ecca63d41d..9a0c52cee47 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
+++ b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
@@ -2,35 +2,37 @@
exports[`publish_method renders 1`] = `
<div
- class="d-flex align-items-center text-secondary order-1 order-md-0 mb-md-1"
+ class="gl-display-flex gl-align-items-center"
>
<gl-icon-stub
- class="mr-1"
+ class="gl-mr-2"
name="git-merge"
size="16"
/>
- <strong
- class="mr-1 text-dark"
+ <span
+ class="gl-mr-2"
+ data-testid="pipeline-ref"
>
branch-name
- </strong>
+ </span>
<gl-icon-stub
- class="mr-1"
+ class="gl-mr-2"
name="commit"
size="16"
/>
<gl-link-stub
- class="mr-1"
+ class="gl-mr-2"
+ data-testid="pipeline-sha"
href="../commit/sha-baz"
>
sha-baz
</gl-link-stub>
<clipboard-button-stub
- cssclass="border-0 text-secondary py-0 px-1"
+ cssclass="gl-border-0 gl-py-0 gl-px-2"
text="sha-baz"
title="Copy commit SHA"
tooltipplacement="top"
diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js
index c0ae972d519..f4eabf7bb67 100644
--- a/spec/frontend/packages/shared/components/package_list_row_spec.js
+++ b/spec/frontend/packages/shared/components/package_list_row_spec.js
@@ -1,6 +1,7 @@
-import { mount, shallowMount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
import PackageTags from '~/packages/shared/components/package_tags.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
import { packageList } from '../../mock_data';
describe('packages_list_row', () => {
@@ -17,14 +18,12 @@ describe('packages_list_row', () => {
const mountComponent = ({
isGroup = false,
packageEntity = packageWithoutTags,
- shallow = true,
showPackageType = true,
disableDelete = false,
} = {}) => {
- const mountFunc = shallow ? shallowMount : mount;
-
- wrapper = mountFunc(PackagesListRow, {
+ wrapper = shallowMount(PackagesListRow, {
store,
+ stubs: { ListItem },
propsData: {
packageLink: 'foo',
packageEntity,
@@ -92,15 +91,14 @@ describe('packages_list_row', () => {
});
describe('delete event', () => {
- beforeEach(() => mountComponent({ packageEntity: packageWithoutTags, shallow: false }));
+ beforeEach(() => mountComponent({ packageEntity: packageWithoutTags }));
- it('emits the packageToDelete event when the delete button is clicked', () => {
- findDeleteButton().trigger('click');
+ it('emits the packageToDelete event when the delete button is clicked', async () => {
+ findDeleteButton().vm.$emit('click');
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('packageToDelete')).toBeTruthy();
- expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]);
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted('packageToDelete')).toBeTruthy();
+ expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]);
});
});
});
diff --git a/spec/frontend/packages/shared/components/packages_list_loader_spec.js b/spec/frontend/packages/shared/components/packages_list_loader_spec.js
index c8c2e2a4ba4..115a3a7095d 100644
--- a/spec/frontend/packages/shared/components/packages_list_loader_spec.js
+++ b/spec/frontend/packages/shared/components/packages_list_loader_spec.js
@@ -12,8 +12,8 @@ describe('PackagesListLoader', () => {
});
};
- const getShapes = () => wrapper.vm.desktopShapes;
- const findSquareButton = () => wrapper.find({ ref: 'button-loader' });
+ const findDesktopShapes = () => wrapper.find('[data-testid="desktop-loader"]');
+ const findMobileShapes = () => wrapper.find('[data-testid="mobile-loader"]');
beforeEach(createComponent);
@@ -22,21 +22,30 @@ describe('PackagesListLoader', () => {
wrapper = null;
});
- describe('when used for projects', () => {
- it('should return 5 rects with last one being a square', () => {
- expect(getShapes()).toHaveLength(5);
- expect(findSquareButton().exists()).toBe(true);
+ describe('desktop loader', () => {
+ it('produces the right loader', () => {
+ expect(findDesktopShapes().findAll('rect[width="1000"]')).toHaveLength(20);
+ });
+
+ it('has the correct classes', () => {
+ expect(findDesktopShapes().classes()).toEqual([
+ 'gl-display-none',
+ 'gl-display-sm-flex',
+ 'gl-flex-direction-column',
+ ]);
});
});
- describe('when used for groups', () => {
- beforeEach(() => {
- createComponent({ isGroup: true });
+ describe('mobile loader', () => {
+ it('produces the right loader', () => {
+ expect(findMobileShapes().findAll('rect[height="170"]')).toHaveLength(5);
});
- it('should return 5 rects with no square', () => {
- expect(getShapes()).toHaveLength(5);
- expect(findSquareButton().exists()).toBe(false);
+ it('has the correct classes', () => {
+ expect(findMobileShapes().classes()).toEqual([
+ 'gl-flex-direction-column',
+ 'gl-display-sm-none',
+ ]);
});
});
});
diff --git a/spec/frontend/packages/shared/components/publish_method_spec.js b/spec/frontend/packages/shared/components/publish_method_spec.js
index bb9287c1204..6014774990c 100644
--- a/spec/frontend/packages/shared/components/publish_method_spec.js
+++ b/spec/frontend/packages/shared/components/publish_method_spec.js
@@ -7,9 +7,9 @@ describe('publish_method', () => {
const [packageWithoutPipeline, packageWithPipeline] = packageList;
- const findPipelineRef = () => wrapper.find({ ref: 'pipeline-ref' });
- const findPipelineSha = () => wrapper.find({ ref: 'pipeline-sha' });
- const findManualPublish = () => wrapper.find({ ref: 'manual-ref' });
+ const findPipelineRef = () => wrapper.find('[data-testid="pipeline-ref"]');
+ const findPipelineSha = () => wrapper.find('[data-testid="pipeline-sha"]');
+ const findManualPublish = () => wrapper.find('[data-testid="manually-published"]');
const mountComponent = (packageEntity = {}, isGroup = false) => {
wrapper = shallowMount(PublishMethod, {
diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
index fc37a545511..2fbc700d4f5 100644
--- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
+++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
@@ -3,14 +3,15 @@
exports[`User Operation confirmation modal renders modal with form included 1`] = `
<div>
<p>
- content
+ <gl-sprintf-stub
+ message="content"
+ />
</p>
<p>
- To confirm, type
- <code>
- username
- </code>
+ <gl-sprintf-stub
+ message="To confirm, type %{username}"
+ />
</p>
<form
diff --git a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js
index b3a297ac2c5..fbe2274c40d 100644
--- a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js
+++ b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { GlBanner } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
+import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import CustomizeHomepageBanner from '~/pages/dashboard/projects/index/components/customize_homepage_banner.vue';
import axios from '~/lib/utils/axios_utils';
@@ -10,18 +11,22 @@ const provide = {
preferencesBehaviorPath: 'some/behavior/path',
calloutsPath: 'call/out/path',
calloutsFeatureId: 'some-feature-id',
+ trackLabel: 'home_page',
};
const createComponent = () => {
- return shallowMount(CustomizeHomepageBanner, { provide });
+ return shallowMount(CustomizeHomepageBanner, { provide, stubs: { GlBanner } });
};
describe('CustomizeHomepageBanner', () => {
+ let trackingSpy;
let mockAxios;
let wrapper;
beforeEach(() => {
mockAxios = new MockAdapter(axios);
+ document.body.dataset.page = 'some:page';
+ trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
wrapper = createComponent();
});
@@ -29,22 +34,75 @@ describe('CustomizeHomepageBanner', () => {
wrapper.destroy();
wrapper = null;
mockAxios.restore();
+ unmockTracking();
});
it('should render the banner when not dismissed', () => {
- expect(wrapper.contains(GlBanner)).toBe(true);
+ expect(wrapper.find(GlBanner).exists()).toBe(true);
});
it('should close the banner when dismiss is clicked', async () => {
mockAxios.onPost(provide.calloutsPath).replyOnce(200);
- expect(wrapper.contains(GlBanner)).toBe(true);
+ expect(wrapper.find(GlBanner).exists()).toBe(true);
wrapper.find(GlBanner).vm.$emit('close');
await wrapper.vm.$nextTick();
- expect(wrapper.contains(GlBanner)).toBe(false);
+ expect(wrapper.find(GlBanner).exists()).toBe(false);
});
it('includes the body text from options', () => {
expect(wrapper.html()).toContain(wrapper.vm.$options.i18n.body);
});
+
+ describe('tracking', () => {
+ const preferencesTrackingEvent = 'click_go_to_preferences';
+ const mockTrackingOnWrapper = () => {
+ unmockTracking();
+ trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
+ };
+
+ it('sets the needed data attributes for tracking button', async () => {
+ await wrapper.vm.$nextTick();
+ const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`);
+
+ expect(button.attributes('data-track-event')).toEqual(preferencesTrackingEvent);
+ expect(button.attributes('data-track-label')).toEqual(provide.trackLabel);
+ });
+
+ it('sends a tracking event when the banner is shown', () => {
+ const trackCategory = undefined;
+ const trackEvent = 'show_home_page_banner';
+
+ expect(trackingSpy).toHaveBeenCalledWith(trackCategory, trackEvent, {
+ label: provide.trackLabel,
+ });
+ });
+
+ it('sends a tracking event when the banner is dismissed', async () => {
+ mockTrackingOnWrapper();
+ mockAxios.onPost(provide.calloutsPath).replyOnce(200);
+ const trackCategory = undefined;
+ const trackEvent = 'click_dismiss';
+
+ wrapper.find(GlBanner).vm.$emit('close');
+
+ await wrapper.vm.$nextTick();
+ expect(trackingSpy).toHaveBeenCalledWith(trackCategory, trackEvent, {
+ label: provide.trackLabel,
+ });
+ });
+
+ it('sends a tracking event when the button is clicked', async () => {
+ mockTrackingOnWrapper();
+ mockAxios.onPost(provide.calloutsPath).replyOnce(200);
+ const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`);
+
+ triggerEvent(button.element);
+
+ await wrapper.vm.$nextTick();
+ expect(trackingSpy).toHaveBeenCalledWith('_category_', preferencesTrackingEvent, {
+ label: provide.trackLabel,
+ });
+ });
+ });
});
diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
index 204fe3d0a68..5ecb7860103 100644
--- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js
+++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
@@ -2,7 +2,6 @@ import $ from 'jquery';
import MockAdapter from 'axios-mock-adapter';
import Todos from '~/pages/dashboard/todos/index/todos';
import '~/lib/utils/common_utils';
-import '~/gl_dropdown';
import axios from '~/lib/utils/axios_utils';
import { addDelimiter } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility';
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 0bb96ee33d4..67ace608127 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
@@ -36,7 +36,7 @@ describe('BitbucketServerStatusTable', () => {
it('renders bitbucket status table component', () => {
createComponent();
- expect(wrapper.contains(BitbucketStatusTable)).toBe(true);
+ expect(wrapper.find(BitbucketStatusTable).exists()).toBe(true);
});
it('renders Reconfigure button', async () => {
diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js
index 2ec608569e3..9993e4da980 100644
--- a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js
@@ -70,7 +70,7 @@ describe('Fork groups list component', () => {
replyWith(() => new Promise(() => {}));
createWrapper();
- expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('displays empty text if no groups are available', async () => {
@@ -89,7 +89,7 @@ describe('Fork groups list component', () => {
await waitForPromises();
- expect(wrapper.contains(GlSearchBoxByType)).toBe(true);
+ expect(wrapper.find(GlSearchBoxByType).exists()).toBe(true);
});
it('renders list items for each available group', async () => {
diff --git a/spec/frontend/pages/projects/graphs/code_coverage_spec.js b/spec/frontend/pages/projects/graphs/code_coverage_spec.js
index 54a080fb62b..8884f7815ab 100644
--- a/spec/frontend/pages/projects/graphs/code_coverage_spec.js
+++ b/spec/frontend/pages/projects/graphs/code_coverage_spec.js
@@ -124,7 +124,7 @@ describe('Code Coverage', () => {
});
it('renders the dropdown with all custom names as options', () => {
- expect(wrapper.contains(GlDeprecatedDropdown)).toBeDefined();
+ expect(wrapper.find(GlDeprecatedDropdown).exists()).toBeDefined();
expect(findAllDropdownItems()).toHaveLength(codeCoverageMockData.length);
expect(findFirstDropdownItem().text()).toBe(codeCoverageMockData[0].group_name);
});
@@ -150,7 +150,11 @@ describe('Code Coverage', () => {
.find(GlIcon)
.exists(),
).toBe(false);
- expect(findSecondDropdownItem().contains(GlIcon)).toBe(true);
+ expect(
+ findSecondDropdownItem()
+ .find(GlIcon)
+ .exists(),
+ ).toBe(true);
});
it('updates the graph data when selecting a different option in dropdown', async () => {
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js
index 4c73225b54c..5efcedf678b 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js
@@ -1,5 +1,4 @@
import $ from 'jquery';
-import '~/gl_dropdown';
import TimezoneDropdown, {
formatUtcOffset,
formatTimezone,
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 8ab5426a005..1fd9d285610 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
@@ -27,14 +27,14 @@ describe('Project Feature Settings', () => {
describe('Hidden name input', () => {
it('should set the hidden name input if the name exists', () => {
- expect(wrapper.find({ name: 'Test' }).props().value).toBe(1);
+ expect(wrapper.find(`input[name=${defaultProps.name}]`).attributes().value).toBe('1');
});
it('should not set the hidden name input if the name does not exist', () => {
wrapper.setProps({ name: null });
return wrapper.vm.$nextTick(() => {
- expect(wrapper.find({ name: 'Test' }).exists()).toBe(false);
+ expect(wrapper.find(`input[name=${defaultProps.name}]`).exists()).toBe(false);
});
});
});
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 a50ceed5d09..e760cead760 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
@@ -40,7 +40,7 @@ const defaultProps = {
pagesAvailable: true,
pagesAccessControlEnabled: false,
pagesAccessControlForced: false,
- pagesHelpPath: '/help/user/project/pages/introduction#gitlab-pages-access-control-core',
+ pagesHelpPath: '/help/user/project/pages/introduction#gitlab-pages-access-control',
packagesAvailable: false,
packagesHelpPath: '/help/user/packages/index',
};
diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js
index b9dc4c9588c..ff51b1184cb 100644
--- a/spec/frontend/performance_bar/components/detailed_metric_spec.js
+++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js
@@ -30,7 +30,7 @@ describe('detailedMetric', () => {
});
it('does not render the element', () => {
- expect(wrapper.isEmpty()).toBe(true);
+ expect(wrapper.html()).toBe('');
});
});
diff --git a/spec/frontend/performance_bar/components/request_warning_spec.js b/spec/frontend/performance_bar/components/request_warning_spec.js
index 21f7bdf01f3..d558c7b018a 100644
--- a/spec/frontend/performance_bar/components/request_warning_spec.js
+++ b/spec/frontend/performance_bar/components/request_warning_spec.js
@@ -27,7 +27,7 @@ describe('request warning', () => {
});
it('does nothing', () => {
- expect(wrapper.isEmpty()).toBe(true);
+ expect(wrapper.html()).toBe('');
});
});
});
diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js
index 621c7d87a7e..1517142c21e 100644
--- a/spec/frontend/performance_bar/index_spec.js
+++ b/spec/frontend/performance_bar/index_spec.js
@@ -44,7 +44,7 @@ describe('performance bar wrapper', () => {
{},
);
- vm = performanceBar({ container: '#js-peek' });
+ vm = performanceBar(peekWrapper);
});
afterEach(() => {
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 cfec4b779e4..36bfd575c12 100644
--- a/spec/frontend/performance_bar/services/performance_bar_service_spec.js
+++ b/spec/frontend/performance_bar/services/performance_bar_service_spec.js
@@ -9,18 +9,12 @@ describe('PerformanceBarService', () => {
it('returns false when the request URL is the peek URL', () => {
expect(
- fireCallback({ headers: { 'x-request-id': '123' }, url: '/peek' }, '/peek'),
- ).toBeFalsy();
+ fireCallback({ headers: { 'x-request-id': '123' }, config: { url: '/peek' } }, '/peek'),
+ ).toBe(false);
});
it('returns false when there is no request ID', () => {
- expect(fireCallback({ headers: {}, url: '/request' }, '/peek')).toBeFalsy();
- });
-
- it('returns false when the request is an API request', () => {
- expect(
- fireCallback({ headers: { 'x-request-id': '123' }, url: '/api/' }, '/peek'),
- ).toBeFalsy();
+ expect(fireCallback({ headers: {}, config: { url: '/request' } }, '/peek')).toBe(false);
});
it('returns false when the response is from the cache', () => {
@@ -29,13 +23,22 @@ describe('PerformanceBarService', () => {
{ headers: { 'x-request-id': '123', 'x-gitlab-from-cache': 'true' }, url: '/request' },
'/peek',
),
- ).toBeFalsy();
+ ).toBe(false);
+ });
+
+ it('returns true when the request is an API request', () => {
+ expect(
+ fireCallback({ headers: { 'x-request-id': '123' }, config: { url: '/api/' } }, '/peek'),
+ ).toBe(true);
});
- it('returns true when all conditions are met', () => {
+ it('returns true for all other requests', () => {
expect(
- fireCallback({ headers: { 'x-request-id': '123' }, url: '/request' }, '/peek'),
- ).toBeTruthy();
+ fireCallback(
+ { headers: { 'x-request-id': '123' }, config: { url: '/request' } },
+ '/peek',
+ ),
+ ).toBe(true);
});
});
@@ -45,7 +48,7 @@ describe('PerformanceBarService', () => {
}
it('gets the request ID from the headers', () => {
- expect(requestId({ headers: { 'x-request-id': '123' } }, '/peek')).toEqual('123');
+ expect(requestId({ headers: { 'x-request-id': '123' } }, '/peek')).toBe('123');
});
});
@@ -54,14 +57,10 @@ describe('PerformanceBarService', () => {
return PerformanceBarService.callbackParams(response, peekUrl)[2];
}
- it('gets the request URL from the response object', () => {
- expect(requestUrl({ headers: {}, url: '/request' }, '/peek')).toEqual('/request');
- });
-
- it('gets the request URL from response.config if present', () => {
- expect(
- requestUrl({ headers: {}, config: { url: '/config-url' }, url: '/request' }, '/peek'),
- ).toEqual('/config-url');
+ it('gets the request URL from response.config', () => {
+ expect(requestUrl({ headers: {}, config: { url: '/config-url' } }, '/peek')).toBe(
+ '/config-url',
+ );
});
});
});
diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
index d1e6b6b938a..97a92778f1a 100644
--- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
+++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
@@ -1,31 +1,48 @@
import { mount, shallowMount } from '@vue/test-utils';
-import { GlNewDropdown, GlNewDropdownItem, GlForm } from '@gitlab/ui';
-import Api from '~/api';
+import { GlDropdown, GlDropdownItem, GlForm, GlSprintf } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue';
-import { mockRefs, mockParams, mockPostParams, mockProjectId } from '../mock_data';
+import { mockRefs, mockParams, mockPostParams, mockProjectId, mockError } from '../mock_data';
+import { redirectTo } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ redirectTo: jest.fn(),
+}));
+
+const pipelinesPath = '/root/project/-/pipleines';
+const postResponse = { id: 1 };
describe('Pipeline New Form', () => {
let wrapper;
+ let mock;
const dummySubmitEvent = {
preventDefault() {},
};
const findForm = () => wrapper.find(GlForm);
- const findDropdown = () => wrapper.find(GlNewDropdown);
- const findDropdownItems = () => wrapper.findAll(GlNewDropdownItem);
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]');
const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]');
const findKeyInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-key"]');
+ const findErrorAlert = () => wrapper.find('[data-testid="run-pipeline-error-alert"]');
+ const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]');
+ const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf);
+ const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]');
+ const getExpectedPostParams = () => JSON.parse(mock.history.post[0].data);
const createComponent = (term = '', props = {}, method = shallowMount) => {
wrapper = method(PipelineNewForm, {
propsData: {
projectId: mockProjectId,
- pipelinesPath: '',
+ pipelinesPath,
refs: mockRefs,
defaultBranch: 'master',
settingsLink: '',
+ maxWarnings: 25,
...props,
},
data() {
@@ -37,24 +54,30 @@ describe('Pipeline New Form', () => {
};
beforeEach(() => {
- jest.spyOn(Api, 'createPipeline').mockResolvedValue({ data: { web_url: '/' } });
+ mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
+
+ mock.restore();
});
describe('Dropdown with branches and tags', () => {
+ beforeEach(() => {
+ mock.onPost(pipelinesPath).reply(200, postResponse);
+ });
+
it('displays dropdown with all branches and tags', () => {
createComponent();
- expect(findDropdownItems().length).toBe(mockRefs.length);
+ expect(findDropdownItems()).toHaveLength(mockRefs.length);
});
it('when user enters search term the list is filtered', () => {
createComponent('master');
- expect(findDropdownItems().length).toBe(1);
+ expect(findDropdownItems()).toHaveLength(1);
expect(
findDropdownItems()
.at(0)
@@ -66,43 +89,78 @@ describe('Pipeline New Form', () => {
describe('Form', () => {
beforeEach(() => {
createComponent('', mockParams, mount);
+
+ mock.onPost(pipelinesPath).reply(200, postResponse);
});
- it('displays the correct values for the provided query params', () => {
+ it('displays the correct values for the provided query params', async () => {
expect(findDropdown().props('text')).toBe('tag-1');
- return wrapper.vm.$nextTick().then(() => {
- expect(findVariableRows().length).toBe(3);
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findVariableRows()).toHaveLength(3);
});
it('does not display remove icon for last row', () => {
- expect(findRemoveIcons().length).toBe(2);
+ expect(findRemoveIcons()).toHaveLength(2);
});
- it('removes ci variable row on remove icon button click', () => {
+ it('removes ci variable row on remove icon button click', async () => {
findRemoveIcons()
.at(1)
.trigger('click');
- return wrapper.vm.$nextTick().then(() => {
- expect(findVariableRows().length).toBe(2);
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findVariableRows()).toHaveLength(2);
});
- it('creates a pipeline on submit', () => {
+ it('creates a pipeline on submit', async () => {
findForm().vm.$emit('submit', dummySubmitEvent);
- expect(Api.createPipeline).toHaveBeenCalledWith(mockProjectId, mockPostParams);
+ await waitForPromises();
+
+ expect(getExpectedPostParams()).toEqual(mockPostParams);
+ expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`);
});
- it('creates blank variable on input change event', () => {
+ it('creates blank variable on input change event', async () => {
findKeyInputs()
.at(2)
.trigger('change');
- return wrapper.vm.$nextTick().then(() => {
- expect(findVariableRows().length).toBe(4);
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findVariableRows()).toHaveLength(4);
+ });
+ });
+
+ describe('Form errors and warnings', () => {
+ beforeEach(() => {
+ createComponent();
+
+ mock.onPost(pipelinesPath).reply(400, mockError);
+
+ findForm().vm.$emit('submit', dummySubmitEvent);
+
+ return waitForPromises();
+ });
+
+ it('shows both error and warning', () => {
+ expect(findErrorAlert().exists()).toBe(true);
+ expect(findWarningAlert().exists()).toBe(true);
+ });
+
+ it('shows the correct error', () => {
+ expect(findErrorAlert().text()).toBe(mockError.errors[0]);
+ });
+
+ it('shows the correct warning title', () => {
+ const { length } = mockError.warnings;
+ expect(findWarningAlertSummary().attributes('message')).toBe(`${length} warnings found:`);
+ });
+
+ it('shows the correct amount of warnings', () => {
+ expect(findWarnings()).toHaveLength(mockError.warnings.length);
});
});
});
diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js
index 55ec1fb5afc..55286e0ec7e 100644
--- a/spec/frontend/pipeline_new/mock_data.js
+++ b/spec/frontend/pipeline_new/mock_data.js
@@ -19,3 +19,15 @@ export const mockPostParams = {
{ key: 'test_file', value: 'test_file_val', variable_type: 'file' },
],
};
+
+export const mockError = {
+ errors: [
+ 'test job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post',
+ ],
+ warnings: [
+ 'jobs:build1 may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings',
+ 'jobs:build2 may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings',
+ 'jobs:build3 may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings',
+ ],
+ total_warnings: 7,
+};
diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
index c5b7318d3af..8a6586a7d7d 100644
--- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
@@ -47,9 +47,6 @@ describe('Pipelines filtered search', () => {
});
it('displays UI elements', () => {
- expect(wrapper.isVueInstance()).toBe(true);
- expect(wrapper.isEmpty()).toBe(false);
-
expect(findFilteredSearch().exists()).toBe(true);
});
diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js
index 1389649abea..d977db58a0e 100644
--- a/spec/frontend/pipelines/graph/graph_component_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_spec.js
@@ -16,6 +16,9 @@ describe('graph component', () => {
let wrapper;
+ const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]');
+ const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]');
+
beforeEach(() => {
setHTMLFixture('<div class="layout-page"></div>');
});
@@ -167,7 +170,7 @@ describe('graph component', () => {
describe('triggered by', () => {
describe('on click', () => {
it('should emit `onClickTriggeredBy` when triggered by linked pipeline is clicked', () => {
- const btnWrapper = wrapper.find('.linked-pipeline-content');
+ const btnWrapper = findExpandPipelineBtn();
btnWrapper.trigger('click');
@@ -213,7 +216,7 @@ describe('graph component', () => {
),
});
- const btnWrappers = wrapper.findAll('.linked-pipeline-content');
+ const btnWrappers = findAllExpandPipelineBtns();
const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1);
downstreamBtnWrapper.trigger('click');
diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js
index 2c5e7a1f6e9..e844cbc5bf8 100644
--- a/spec/frontend/pipelines/graph/job_item_spec.js
+++ b/spec/frontend/pipelines/graph/job_item_spec.js
@@ -6,6 +6,7 @@ describe('pipeline graph job item', () => {
let wrapper;
const findJobWithoutLink = () => wrapper.find('[data-testid="job-without-link"]');
+ const findJobWithLink = () => wrapper.find('[data-testid="job-with-link"]');
const createWrapper = propsData => {
wrapper = mount(JobItem, {
@@ -13,6 +14,7 @@ describe('pipeline graph job item', () => {
});
};
+ const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500';
const delayedJobFixture = getJSONFixture('jobs/delayed.json');
const mockJob = {
id: 4256,
@@ -33,6 +35,18 @@ describe('pipeline graph job item', () => {
},
},
};
+ const mockJobWithoutDetails = {
+ id: 4257,
+ name: 'job_without_details',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ details_path: '/root/ci-mock/builds/4257',
+ has_details: false,
+ },
+ };
afterEach(() => {
wrapper.destroy();
@@ -47,7 +61,7 @@ describe('pipeline graph job item', () => {
expect(link.attributes('href')).toBe(mockJob.status.details_path);
- expect(link.attributes('title')).toEqual(`${mockJob.name} - ${mockJob.status.label}`);
+ expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`);
expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true);
@@ -61,18 +75,7 @@ describe('pipeline graph job item', () => {
describe('name without link', () => {
beforeEach(() => {
createWrapper({
- job: {
- id: 4257,
- name: 'test',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- details_path: '/root/ci-mock/builds/4257',
- has_details: false,
- },
- },
+ job: mockJobWithoutDetails,
cssClassJobName: 'css-class-job-name',
jobHovered: 'test',
});
@@ -82,11 +85,10 @@ describe('pipeline graph job item', () => {
expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true);
expect(wrapper.find('a').exists()).toBe(false);
- expect(trimText(wrapper.find('.ci-status-text').text())).toEqual(mockJob.name);
+ expect(trimText(wrapper.find('.ci-status-text').text())).toBe(mockJobWithoutDetails.name);
});
it('should apply hover class and provided class name', () => {
- expect(findJobWithoutLink().classes()).toContain('gl-inset-border-1-blue-500');
expect(findJobWithoutLink().classes()).toContain('css-class-job-name');
});
});
@@ -137,9 +139,7 @@ describe('pipeline graph job item', () => {
},
});
- expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toEqual(
- 'test - success',
- );
+ expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test - success');
});
});
@@ -149,9 +149,39 @@ describe('pipeline graph job item', () => {
job: delayedJobFixture,
});
- expect(wrapper.find('.js-pipeline-graph-job-link').attributes('title')).toEqual(
+ expect(findJobWithLink().attributes('title')).toBe(
`delayed job - delayed manual action (${wrapper.vm.remainingTime})`,
);
});
});
+
+ describe('trigger job highlighting', () => {
+ it.each`
+ job | jobName | expanded | link
+ ${mockJob} | ${mockJob.name} | ${true} | ${true}
+ ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${true} | ${false}
+ `(
+ `trigger job should stay highlighted when downstream is expanded`,
+ ({ job, jobName, expanded, link }) => {
+ createWrapper({ job, pipelineExpanded: { jobName, expanded } });
+ const findJobEl = link ? findJobWithLink : findJobWithoutLink;
+
+ expect(findJobEl().classes()).toContain(triggerActiveClass);
+ },
+ );
+
+ it.each`
+ job | jobName | expanded | link
+ ${mockJob} | ${mockJob.name} | ${false} | ${true}
+ ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${false} | ${false}
+ `(
+ `trigger job should not be highlighted when downstream is not expanded`,
+ ({ job, jobName, expanded, link }) => {
+ createWrapper({ job, pipelineExpanded: { jobName, expanded } });
+ const findJobEl = link ? findJobWithLink : findJobWithoutLink;
+
+ expect(findJobEl().classes()).not.toContain(triggerActiveClass);
+ },
+ );
+ });
});
diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
index 59121c54ff3..8e65f0d4f71 100644
--- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
@@ -16,10 +16,18 @@ describe('Linked pipeline', () => {
const findButton = () => wrapper.find(GlButton);
const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]');
const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' });
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+ const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]');
+ const findExpandButton = () => wrapper.find('[data-testid="expandPipelineButton"]');
- const createWrapper = propsData => {
+ const createWrapper = (propsData, data = []) => {
wrapper = mount(LinkedPipelineComponent, {
propsData,
+ data() {
+ return {
+ ...data,
+ };
+ },
});
};
@@ -39,7 +47,7 @@ describe('Linked pipeline', () => {
});
it('should render a list item as the containing element', () => {
- expect(wrapper.is('li')).toBe(true);
+ expect(wrapper.element.tagName).toBe('LI');
});
it('should render a button', () => {
@@ -76,7 +84,7 @@ describe('Linked pipeline', () => {
});
it('should render the tooltip text as the title attribute', () => {
- const titleAttr = findButton().attributes('title');
+ const titleAttr = findLinkedPipeline().attributes('title');
expect(titleAttr).toContain(mockPipeline.project.name);
expect(titleAttr).toContain(mockPipeline.details.status.label);
@@ -117,6 +125,56 @@ describe('Linked pipeline', () => {
createWrapper(upstreamProps);
expect(findPipelineLabel().exists()).toBe(true);
});
+
+ it('downstream pipeline should contain the correct link', () => {
+ createWrapper(downstreamProps);
+ expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path);
+ });
+
+ it('upstream pipeline should contain the correct link', () => {
+ createWrapper(upstreamProps);
+ expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path);
+ });
+
+ it.each`
+ presentClass | missingClass
+ ${'gl-right-0'} | ${'gl-left-0'}
+ ${'gl-border-l-1!'} | ${'gl-border-r-1!'}
+ `(
+ 'pipeline expand button should be postioned right when child pipeline',
+ ({ presentClass, missingClass }) => {
+ createWrapper(downstreamProps);
+ expect(findExpandButton().classes()).toContain(presentClass);
+ expect(findExpandButton().classes()).not.toContain(missingClass);
+ },
+ );
+
+ it.each`
+ presentClass | missingClass
+ ${'gl-left-0'} | ${'gl-right-0'}
+ ${'gl-border-r-1!'} | ${'gl-border-l-1!'}
+ `(
+ 'pipeline expand button should be postioned left when parent pipeline',
+ ({ presentClass, missingClass }) => {
+ createWrapper(upstreamProps);
+ expect(findExpandButton().classes()).toContain(presentClass);
+ expect(findExpandButton().classes()).not.toContain(missingClass);
+ },
+ );
+
+ it.each`
+ pipelineType | anglePosition | expanded
+ ${downstreamProps} | ${'angle-right'} | ${false}
+ ${downstreamProps} | ${'angle-left'} | ${true}
+ ${upstreamProps} | ${'angle-left'} | ${false}
+ ${upstreamProps} | ${'angle-right'} | ${true}
+ `(
+ '$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded',
+ ({ pipelineType, anglePosition, expanded }) => {
+ createWrapper(pipelineType, { expanded });
+ expect(findExpandButton().props('icon')).toBe(anglePosition);
+ },
+ );
});
describe('when isLoading is true', () => {
@@ -130,8 +188,8 @@ describe('Linked pipeline', () => {
createWrapper(props);
});
- it('sets the loading prop to true', () => {
- expect(findButton().props('loading')).toBe(true);
+ it('loading icon is visible', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
});
});
@@ -172,5 +230,10 @@ describe('Linked pipeline', () => {
findLinkedPipeline().trigger('mouseleave');
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([['trigger_job', true]]);
+ });
});
});
diff --git a/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js b/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js
new file mode 100644
index 00000000000..fea42350959
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js
@@ -0,0 +1,47 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlTab } from '@gitlab/ui';
+import { yamlString } from './mock_data';
+import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
+import GitlabCiYamlVisualization from '~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue';
+
+describe('gitlab yaml visualization component', () => {
+ const defaultProps = { blobData: yamlString };
+ let wrapper;
+
+ const createComponent = props => {
+ return shallowMount(GitlabCiYamlVisualization, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ const findGlTabComponents = () => wrapper.findAll(GlTab);
+ const findPipelineGraph = () => wrapper.find(PipelineGraph);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('tabs component', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('renders the file and visualization tabs', () => {
+ expect(findGlTabComponents()).toHaveLength(2);
+ });
+ });
+
+ describe('graph component', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('is hidden by default', () => {
+ expect(findPipelineGraph().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js
new file mode 100644
index 00000000000..5a5d6c021a6
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js
@@ -0,0 +1,80 @@
+export const yamlString = `stages:
+- empty
+- build
+- test
+- deploy
+- final
+
+include:
+- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml'
+
+build_a:
+ stage: build
+ script: echo hello
+build_b:
+ stage: build
+ script: echo hello
+build_c:
+ stage: build
+ script: echo hello
+build_d:
+ stage: Queen
+ script: echo hello
+
+test_a:
+ stage: test
+ script: ls
+ needs: [build_a, build_b, build_c]
+test_b:
+ stage: test
+ script: ls
+ needs: [build_a, build_b, build_d]
+test_c:
+ stage: test
+ script: ls
+ needs: [build_a, build_b, build_c]
+
+deploy_a:
+ stage: deploy
+ script: echo hello
+`;
+
+export const pipelineData = {
+ stages: [
+ {
+ name: 'build',
+ groups: [],
+ },
+ {
+ name: 'build',
+ groups: [
+ {
+ name: 'build_1',
+ jobs: [{ script: 'echo hello', stage: 'build' }],
+ },
+ ],
+ },
+ {
+ name: 'test',
+ groups: [
+ {
+ name: 'test_1',
+ jobs: [{ script: 'yarn test', stage: 'test' }],
+ },
+ {
+ name: 'test_2',
+ jobs: [{ script: 'yarn karma', stage: 'test' }],
+ },
+ ],
+ },
+ {
+ name: 'deploy',
+ groups: [
+ {
+ name: 'deploy_1',
+ jobs: [{ script: 'yarn magick', stage: 'deploy' }],
+ },
+ ],
+ },
+ ],
+};
diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
new file mode 100644
index 00000000000..30e192e5726
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import { pipelineData } from './mock_data';
+import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
+import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue';
+import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
+
+describe('pipeline graph component', () => {
+ const defaultProps = { pipelineData };
+ let wrapper;
+
+ const createComponent = props => {
+ return shallowMount(PipelineGraph, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ const findAllStagePills = () => wrapper.findAll(StagePill);
+ const findAllJobPills = () => wrapper.findAll(JobPill);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('with no data', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ pipelineData: {} });
+ });
+
+ it('renders an empty section', () => {
+ expect(wrapper.text()).toContain('No content to show');
+ expect(findAllStagePills()).toHaveLength(0);
+ expect(findAllJobPills()).toHaveLength(0);
+ });
+ });
+
+ describe('with data', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+ it('renders the right number of stage pills', () => {
+ const expectedStagesLength = pipelineData.stages.length;
+
+ expect(findAllStagePills()).toHaveLength(expectedStagesLength);
+ });
+
+ it('renders the right number of job pills', () => {
+ // We count the number of jobs in the mock data
+ const expectedJobsLength = pipelineData.stages.reduce((acc, val) => {
+ return acc + val.groups.length;
+ }, 0);
+
+ expect(findAllJobPills()).toHaveLength(expectedJobsLength);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js
new file mode 100644
index 00000000000..dd85c8c2bd0
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js
@@ -0,0 +1,150 @@
+import { preparePipelineGraphData } from '~/pipelines/utils';
+
+describe('preparePipelineGraphData', () => {
+ const emptyResponse = { stages: [] };
+ const jobName1 = 'build_1';
+ const jobName2 = 'build_2';
+ const jobName3 = 'test_1';
+ const jobName4 = 'deploy_1';
+ const job1 = { [jobName1]: { script: 'echo hello', stage: 'build' } };
+ const job2 = { [jobName2]: { script: 'echo build', stage: 'build' } };
+ const job3 = { [jobName3]: { script: 'echo test', stage: 'test' } };
+ const job4 = { [jobName4]: { script: 'echo deploy', stage: 'deploy' } };
+
+ describe('returns an object with an empty array of stages if', () => {
+ it('no data is passed', () => {
+ expect(preparePipelineGraphData({})).toEqual(emptyResponse);
+ });
+
+ it('no stages are found', () => {
+ expect(preparePipelineGraphData({ includes: 'template/myTemplate.gitlab-ci.yml' })).toEqual(
+ emptyResponse,
+ );
+ });
+ });
+
+ describe('returns the correct array of stages', () => {
+ it('when multiple jobs are in the same stage', () => {
+ const expectedData = {
+ stages: [
+ {
+ name: job1[jobName1].stage,
+ groups: [
+ {
+ name: jobName1,
+ jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }],
+ },
+ {
+ name: jobName2,
+ jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }],
+ },
+ ],
+ },
+ ],
+ };
+
+ expect(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData);
+ });
+
+ it('when stages are defined by the user', () => {
+ const userDefinedStage = 'myStage';
+ const userDefinedStage2 = 'myStage2';
+
+ const expectedData = {
+ stages: [
+ {
+ name: userDefinedStage,
+ groups: [],
+ },
+ {
+ name: userDefinedStage2,
+ groups: [],
+ },
+ ],
+ };
+
+ expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual(
+ expectedData,
+ );
+ });
+
+ it('by combining user defined stage and job stages, it preserves user defined order', () => {
+ const userDefinedStage = 'myStage';
+ const userDefinedStageThatOverlaps = 'deploy';
+
+ const expectedData = {
+ stages: [
+ {
+ name: userDefinedStage,
+ groups: [],
+ },
+ {
+ name: job4[jobName4].stage,
+ groups: [
+ {
+ name: jobName4,
+ jobs: [{ script: job4[jobName4].script, stage: job4[jobName4].stage }],
+ },
+ ],
+ },
+ {
+ name: job1[jobName1].stage,
+ groups: [
+ {
+ name: jobName1,
+ jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }],
+ },
+ {
+ name: jobName2,
+ jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }],
+ },
+ ],
+ },
+ {
+ name: job3[jobName3].stage,
+ groups: [
+ {
+ name: jobName3,
+ jobs: [{ script: job3[jobName3].script, stage: job3[jobName3].stage }],
+ },
+ ],
+ },
+ ],
+ };
+
+ expect(
+ preparePipelineGraphData({
+ stages: [userDefinedStage, userDefinedStageThatOverlaps],
+ ...job1,
+ ...job2,
+ ...job3,
+ ...job4,
+ }),
+ ).toEqual(expectedData);
+ });
+
+ it('with only unique values', () => {
+ const expectedData = {
+ stages: [
+ {
+ name: job1[jobName1].stage,
+ groups: [
+ {
+ name: jobName1,
+ jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }],
+ },
+ ],
+ },
+ ],
+ };
+
+ expect(
+ preparePipelineGraphData({
+ stages: ['build'],
+ ...job1,
+ ...job1,
+ }),
+ ).toEqual(expectedData);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js
index 6fd9a143d82..ad8136890e6 100644
--- a/spec/frontend/pipelines/pipeline_triggerer_spec.js
+++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js
@@ -36,7 +36,7 @@ describe('Pipelines Triggerer', () => {
});
it('should render a table cell', () => {
- expect(wrapper.contains('.table-section')).toBe(true);
+ expect(wrapper.find('.table-section').exists()).toBe(true);
});
it('should pass triggerer information when triggerer is provided', () => {
diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js
index cce4c2dfa7b..071a2b24889 100644
--- a/spec/frontend/pipelines/pipelines_actions_spec.js
+++ b/spec/frontend/pipelines/pipelines_actions_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'spec/test_constants';
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import PipelinesActions from '~/pipelines/components/pipelines_list/pipelines_actions.vue';
@@ -19,7 +19,7 @@ describe('Pipelines Actions dropdown', () => {
});
};
- const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedButton);
+ const findAllDropdownItems = () => wrapper.findAll(GlButton);
const findAllCountdowns = () => wrapper.findAll(GlCountdown);
beforeEach(() => {
@@ -66,7 +66,7 @@ describe('Pipelines Actions dropdown', () => {
it('makes a request and toggles the loading state', () => {
mock.onPost(mockActions.path).reply(200);
- wrapper.find(GlDeprecatedButton).vm.$emit('click');
+ wrapper.find(GlButton).vm.$emit('click');
expect(wrapper.vm.isLoading).toBe(true);
diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js
index ca9ebb54138..58e8065033f 100644
--- a/spec/frontend/pipelines/test_reports/stores/getters_spec.js
+++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js
@@ -1,6 +1,6 @@
import { getJSONFixture } from 'helpers/fixtures';
import * as getters from '~/pipelines/stores/test_reports/getters';
-import { iconForTestStatus } from '~/pipelines/stores/test_reports/utils';
+import { iconForTestStatus, formattedTime } from '~/pipelines/stores/test_reports/utils';
describe('Getters TestReports Store', () => {
let state;
@@ -34,7 +34,7 @@ describe('Getters TestReports Store', () => {
const suites = getters.getTestSuites(state);
const expected = testReports.test_suites.map(x => ({
...x,
- formattedTime: '00:00:00',
+ formattedTime: formattedTime(x.total_time),
}));
expect(suites).toEqual(expected);
@@ -65,7 +65,7 @@ describe('Getters TestReports Store', () => {
const cases = getters.getSuiteTests(state);
const expected = testReports.test_suites[0].test_cases.map(x => ({
...x,
- formattedTime: '00:00:00',
+ formattedTime: formattedTime(x.execution_time),
icon: iconForTestStatus(x.status),
}));
diff --git a/spec/frontend/pipelines/test_reports/stores/utils_spec.js b/spec/frontend/pipelines/test_reports/stores/utils_spec.js
new file mode 100644
index 00000000000..7e632d099fc
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/stores/utils_spec.js
@@ -0,0 +1,26 @@
+import { formattedTime } from '~/pipelines/stores/test_reports/utils';
+
+describe('Test reports utils', () => {
+ describe('formattedTime', () => {
+ describe('when time is smaller than a second', () => {
+ it('should return time in milliseconds fixed to 2 decimals', () => {
+ const result = formattedTime(0.4815162342);
+ expect(result).toBe('481.52ms');
+ });
+ });
+
+ describe('when time is equal to a second', () => {
+ it('should return time in seconds fixed to 2 decimals', () => {
+ const result = formattedTime(1);
+ expect(result).toBe('1.00s');
+ });
+ });
+
+ describe('when time is greater than a second', () => {
+ it('should return time in seconds fixed to 2 decimals', () => {
+ const result = formattedTime(4.815162342);
+ expect(result).toBe('4.82s');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js
index a709edf5184..c8ab18b9086 100644
--- a/spec/frontend/pipelines/test_reports/test_reports_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js
@@ -1,4 +1,5 @@
import Vuex from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { getJSONFixture } from 'helpers/fixtures';
import TestReports from '~/pipelines/components/test_reports/test_reports.vue';
@@ -15,9 +16,9 @@ describe('Test reports app', () => {
const testReports = getJSONFixture('pipelines/test_report.json');
- const loadingSpinner = () => wrapper.find('.js-loading-spinner');
- const testsDetail = () => wrapper.find('.js-tests-detail');
- const noTestsToShow = () => wrapper.find('.js-no-tests-to-show');
+ const loadingSpinner = () => wrapper.find(GlLoadingIcon);
+ const testsDetail = () => wrapper.find('[data-testid="tests-detail"]');
+ const noTestsToShow = () => wrapper.find('[data-testid="no-tests-to-show"]');
const testSummary = () => wrapper.find(TestSummary);
const testSummaryTable = () => wrapper.find(TestSummaryTable);
@@ -88,6 +89,10 @@ describe('Test reports app', () => {
expect(wrapper.vm.testReports).toBeTruthy();
expect(wrapper.vm.showTests).toBeTruthy();
});
+
+ it('shows tests details', () => {
+ expect(testsDetail().exists()).toBe(true);
+ });
});
describe('when a suite is clicked', () => {
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 3a4aa94571e..2feb6aa5799 100644
--- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
@@ -23,8 +23,6 @@ describe('Test reports suite table', () => {
const noCasesMessage = () => wrapper.find('.js-no-test-cases');
const allCaseRows = () => wrapper.findAll('.js-case-row');
const findCaseRowAtIndex = index => wrapper.findAll('.js-case-row').at(index);
- const allCaseNames = () =>
- wrapper.findAll('[data-testid="caseName"]').wrappers.map(el => el.attributes('text'));
const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`);
const createComponent = (suite = testSuite) => {
@@ -63,16 +61,6 @@ describe('Test reports suite table', () => {
expect(allCaseRows().length).toBe(testCases.length);
});
- it('renders the failed tests first, skipped tests next, then successful tests', () => {
- const expectedCaseOrder = [
- ...testCases.filter(x => x.status === TestStatus.FAILED),
- ...testCases.filter(x => x.status === TestStatus.SKIPPED),
- ...testCases.filter(x => x.status === TestStatus.SUCCESS),
- ].map(x => x.name);
-
- expect(allCaseNames()).toEqual(expectedCaseOrder);
- });
-
it('renders the correct icon for each status', () => {
const failedTest = testCases.findIndex(x => x.status === TestStatus.FAILED);
const skippedTest = testCases.findIndex(x => x.status === TestStatus.SKIPPED);
diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js
index 79be6c168cf..dc5af7b160c 100644
--- a/spec/frontend/pipelines/test_reports/test_summary_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils';
import { getJSONFixture } from 'helpers/fixtures';
import Summary from '~/pipelines/components/test_reports/test_summary.vue';
+import { formattedTime } from '~/pipelines/stores/test_reports/utils';
describe('Test reports summary', () => {
let wrapper;
@@ -76,7 +77,7 @@ describe('Test reports summary', () => {
});
it('displays the correctly formatted duration', () => {
- expect(duration().text()).toBe('00:00:00');
+ expect(duration().text()).toBe(formattedTime(testSuite.total_time));
});
});
diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js
index 04934fb93b0..b7bc8d08a0f 100644
--- a/spec/frontend/pipelines/time_ago_spec.js
+++ b/spec/frontend/pipelines/time_ago_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue';
describe('Timeago component', () => {
@@ -22,14 +23,19 @@ describe('Timeago component', () => {
wrapper = null;
});
+ const duration = () => wrapper.find('.duration');
+ const finishedAt = () => wrapper.find('.finished-at');
+
describe('with duration', () => {
beforeEach(() => {
createComponent({ duration: 10, finishedTime: '' });
});
it('should render duration and timer svg', () => {
- expect(wrapper.find('.duration').exists()).toBe(true);
- expect(wrapper.find('.duration svg').exists()).toBe(true);
+ const icon = duration().find(GlIcon);
+
+ expect(duration().exists()).toBe(true);
+ expect(icon.props('name')).toBe('timer');
});
});
@@ -39,7 +45,7 @@ describe('Timeago component', () => {
});
it('should not render duration and timer svg', () => {
- expect(wrapper.find('.duration').exists()).toBe(false);
+ expect(duration().exists()).toBe(false);
});
});
@@ -49,9 +55,12 @@ describe('Timeago component', () => {
});
it('should render time and calendar icon', () => {
- expect(wrapper.find('.finished-at').exists()).toBe(true);
- expect(wrapper.find('.finished-at i.fa-calendar').exists()).toBe(true);
- expect(wrapper.find('.finished-at time').exists()).toBe(true);
+ const icon = finishedAt().find(GlIcon);
+ const time = finishedAt().find('time');
+
+ expect(finishedAt().exists()).toBe(true);
+ expect(icon.props('name')).toBe('calendar');
+ expect(time.exists()).toBe(true);
});
});
@@ -61,7 +70,7 @@ describe('Timeago component', () => {
});
it('should not render time and calendar icon', () => {
- expect(wrapper.find('.finished-at').exists()).toBe(false);
+ expect(finishedAt().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js
index 096e4cd97f6..b53955ab743 100644
--- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js
@@ -5,16 +5,17 @@ import PipelineStatusToken from '~/pipelines/components/pipelines_list/tokens/pi
describe('Pipeline Status Token', () => {
let wrapper;
- const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
- const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
- const findAllGlIcons = () => wrapper.findAll(GlIcon);
-
const stubs = {
GlFilteredSearchToken: {
+ props: GlFilteredSearchToken.props,
template: `<div><slot name="suggestions"></slot></div>`,
},
};
+ const findFilteredSearchToken = () => wrapper.find(stubs.GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
+ const findAllGlIcons = () => wrapper.findAll(GlIcon);
+
const defaultProps = {
config: {
type: 'status',
@@ -27,12 +28,12 @@ describe('Pipeline Status Token', () => {
},
};
- const createComponent = options => {
+ const createComponent = () => {
wrapper = shallowMount(PipelineStatusToken, {
propsData: {
...defaultProps,
},
- ...options,
+ stubs,
});
};
@@ -50,10 +51,6 @@ describe('Pipeline Status Token', () => {
});
describe('shows statuses correctly', () => {
- beforeEach(() => {
- createComponent({ stubs });
- });
-
it('renders all pipeline statuses available', () => {
expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.statuses.length);
expect(findAllGlIcons()).toHaveLength(wrapper.vm.statuses.length);
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 c95d2ea1b7b..9363944a719 100644
--- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
@@ -7,16 +7,17 @@ import { users } from '../mock_data';
describe('Pipeline Trigger Author Token', () => {
let wrapper;
- const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
- const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
- const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
-
const stubs = {
GlFilteredSearchToken: {
+ props: GlFilteredSearchToken.props,
template: `<div><slot name="suggestions"></slot></div>`,
},
};
+ const findFilteredSearchToken = () => wrapper.find(stubs.GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
const defaultProps = {
config: {
type: 'username',
@@ -31,7 +32,7 @@ describe('Pipeline Trigger Author Token', () => {
},
};
- const createComponent = (options, data) => {
+ const createComponent = data => {
wrapper = shallowMount(PipelineTriggerAuthorToken, {
propsData: {
...defaultProps,
@@ -41,7 +42,7 @@ describe('Pipeline Trigger Author Token', () => {
...data,
};
},
- ...options,
+ stubs,
});
};
@@ -69,13 +70,13 @@ describe('Pipeline Trigger Author Token', () => {
describe('displays loading icon correctly', () => {
it('shows loading icon', () => {
- createComponent({ stubs }, { loading: true });
+ createComponent({ loading: true });
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not show loading icon', () => {
- createComponent({ stubs }, { loading: false });
+ createComponent({ loading: false });
expect(findLoadingIcon().exists()).toBe(false);
});
@@ -85,22 +86,17 @@ describe('Pipeline Trigger Author Token', () => {
beforeEach(() => {});
it('renders all trigger authors', () => {
- createComponent({ stubs }, { users, loading: false });
+ createComponent({ users, loading: false });
// should have length of all users plus the static 'Any' option
expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1);
});
it('renders only the trigger author searched for', () => {
- createComponent(
- { stubs },
- {
- users: [
- { name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' },
- ],
- loading: false,
- },
- );
+ createComponent({
+ users: [{ name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' }],
+ loading: false,
+ });
expect(findAllFilteredSearchSuggestions()).toHaveLength(2);
});
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 4da82152818..7834456f7c4 100644
--- a/spec/frontend/profile/account/components/delete_account_modal_spec.js
+++ b/spec/frontend/profile/account/components/delete_account_modal_spec.js
@@ -1,21 +1,49 @@
import Vue from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
-import mountComponent from 'helpers/vue_mount_component_helper';
+import { merge } from 'lodash';
+import { mount } from '@vue/test-utils';
import deleteAccountModal from '~/profile/account/components/delete_account_modal.vue';
+const GlModalStub = {
+ name: 'gl-modal-stub',
+ template: `
+ <div>
+ <slot></slot>
+ </div>
+ `,
+};
+
describe('DeleteAccountModal component', () => {
const actionUrl = `${TEST_HOST}/delete/user`;
const username = 'hasnoname';
- let Component;
+ let wrapper;
let vm;
- beforeEach(() => {
- Component = Vue.extend(deleteAccountModal);
- });
+ const createWrapper = (options = {}) => {
+ wrapper = mount(
+ deleteAccountModal,
+ merge(
+ {},
+ {
+ propsData: {
+ actionUrl,
+ username,
+ },
+ stubs: {
+ GlModal: GlModalStub,
+ },
+ },
+ options,
+ ),
+ );
+ vm = wrapper.vm;
+ };
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ wrapper = null;
+ vm = null;
});
const findElements = () => {
@@ -23,16 +51,16 @@ describe('DeleteAccountModal component', () => {
return {
form: vm.$refs.form,
input: vm.$el.querySelector(`[name="${confirmation}"]`),
- submitButton: vm.$el.querySelector('.btn-danger'),
};
};
+ const findModal = () => wrapper.find(GlModalStub);
describe('with password confirmation', () => {
beforeEach(done => {
- vm = mountComponent(Component, {
- actionUrl,
- confirmWithPassword: true,
- username,
+ createWrapper({
+ propsData: {
+ confirmWithPassword: true,
+ },
});
vm.isOpen = true;
@@ -43,7 +71,7 @@ describe('DeleteAccountModal component', () => {
});
it('does not accept empty password', done => {
- const { form, input, submitButton } = findElements();
+ const { form, input } = findElements();
jest.spyOn(form, 'submit').mockImplementation(() => {});
input.value = '';
input.dispatchEvent(new Event('input'));
@@ -51,8 +79,8 @@ describe('DeleteAccountModal component', () => {
Vue.nextTick()
.then(() => {
expect(vm.enteredPassword).toBe(input.value);
- expect(submitButton).toHaveAttr('disabled', 'disabled');
- submitButton.click();
+ expect(findModal().attributes('ok-disabled')).toBe('true');
+ findModal().vm.$emit('primary');
expect(form.submit).not.toHaveBeenCalled();
})
@@ -61,7 +89,7 @@ describe('DeleteAccountModal component', () => {
});
it('submits form with password', done => {
- const { form, input, submitButton } = findElements();
+ const { form, input } = findElements();
jest.spyOn(form, 'submit').mockImplementation(() => {});
input.value = 'anything';
input.dispatchEvent(new Event('input'));
@@ -69,8 +97,8 @@ describe('DeleteAccountModal component', () => {
Vue.nextTick()
.then(() => {
expect(vm.enteredPassword).toBe(input.value);
- expect(submitButton).not.toHaveAttr('disabled', 'disabled');
- submitButton.click();
+ expect(findModal().attributes('ok-disabled')).toBeUndefined();
+ findModal().vm.$emit('primary');
expect(form.submit).toHaveBeenCalled();
})
@@ -81,10 +109,10 @@ describe('DeleteAccountModal component', () => {
describe('with username confirmation', () => {
beforeEach(done => {
- vm = mountComponent(Component, {
- actionUrl,
- confirmWithPassword: false,
- username,
+ createWrapper({
+ propsData: {
+ confirmWithPassword: false,
+ },
});
vm.isOpen = true;
@@ -95,7 +123,7 @@ describe('DeleteAccountModal component', () => {
});
it('does not accept wrong username', done => {
- const { form, input, submitButton } = findElements();
+ const { form, input } = findElements();
jest.spyOn(form, 'submit').mockImplementation(() => {});
input.value = 'this is wrong';
input.dispatchEvent(new Event('input'));
@@ -103,8 +131,8 @@ describe('DeleteAccountModal component', () => {
Vue.nextTick()
.then(() => {
expect(vm.enteredUsername).toBe(input.value);
- expect(submitButton).toHaveAttr('disabled', 'disabled');
- submitButton.click();
+ expect(findModal().attributes('ok-disabled')).toBe('true');
+ findModal().vm.$emit('primary');
expect(form.submit).not.toHaveBeenCalled();
})
@@ -113,7 +141,7 @@ describe('DeleteAccountModal component', () => {
});
it('submits form with correct username', done => {
- const { form, input, submitButton } = findElements();
+ const { form, input } = findElements();
jest.spyOn(form, 'submit').mockImplementation(() => {});
input.value = username;
input.dispatchEvent(new Event('input'));
@@ -121,8 +149,8 @@ describe('DeleteAccountModal component', () => {
Vue.nextTick()
.then(() => {
expect(vm.enteredUsername).toBe(input.value);
- expect(submitButton).not.toHaveAttr('disabled', 'disabled');
- submitButton.click();
+ expect(findModal().attributes('ok-disabled')).toBeUndefined();
+ findModal().vm.$emit('primary');
expect(form.submit).toHaveBeenCalled();
})
diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js
index d6fac6f5f79..68c285a4097 100644
--- a/spec/frontend/projects/commits/components/author_select_spec.js
+++ b/spec/frontend/projects/commits/components/author_select_spec.js
@@ -1,11 +1,6 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
-import {
- GlNewDropdown,
- GlNewDropdownHeader,
- GlSearchBoxByType,
- GlNewDropdownItem,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownSectionHeader, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import * as urlUtility from '~/lib/utils/url_utility';
import AuthorSelect from '~/projects/commits/components/author_select.vue';
import { createStore } from '~/projects/commits/store';
@@ -63,10 +58,10 @@ describe('Author Select', () => {
});
const findDropdownContainer = () => wrapper.find({ ref: 'dropdownContainer' });
- const findDropdown = () => wrapper.find(GlNewDropdown);
- const findDropdownHeader = () => wrapper.find(GlNewDropdownHeader);
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownHeader = () => wrapper.find(GlDropdownSectionHeader);
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
- const findDropdownItems = () => wrapper.findAll(GlNewDropdownItem);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
describe('user is searching via "filter by commit message"', () => {
it('disables dropdown container', () => {
@@ -133,11 +128,7 @@ describe('Author Select', () => {
const authorName = 'lorem';
findSearchBox().vm.$emit('input', authorName);
- expect(store.actions.fetchAuthors).toHaveBeenCalledWith(
- expect.anything(),
- authorName,
- undefined,
- );
+ expect(store.actions.fetchAuthors).toHaveBeenCalledWith(expect.anything(), authorName);
});
});
diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
index 44220bdef64..455467e7b29 100644
--- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
@@ -51,12 +51,12 @@ exports[`Project remove modal initialized matches the snapshot 1`] = `
variant="danger"
>
<gl-sprintf-stub
- message="Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its respositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc."
+ message="Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc."
/>
</gl-alert-stub>
<p>
- This action cannot be undone. You will lose the project's respository and all conent: issues, merge requests, etc.
+ This action cannot be undone. You will lose the project's repository and all content: issues, merge requests, etc.
</p>
<p
@@ -66,7 +66,9 @@ exports[`Project remove modal initialized matches the snapshot 1`] = `
</p>
<p>
- <code>
+ <code
+ class="gl-white-space-pre-wrap"
+ >
foo
</code>
</p>
diff --git a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
index a43acc8c002..692b8f6cf52 100644
--- a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
@@ -55,7 +55,9 @@ exports[`Project remove modal intialized matches the snapshot 1`] = `
</p>
<p>
- <code>
+ <code
+ class="gl-white-space-pre-wrap"
+ >
foo
</code>
</p>
diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js
index 6d323b0408b..3b375c5610f 100644
--- a/spec/frontend/projects/settings/access_dropdown_spec.js
+++ b/spec/frontend/projects/settings/access_dropdown_spec.js
@@ -1,5 +1,4 @@
import $ from 'jquery';
-import '~/gl_dropdown';
import AccessDropdown from '~/projects/settings/access_dropdown';
import { LEVEL_TYPES } from '~/projects/settings/constants';
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 4c873bdfd60..0f3b699f6b2 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
@@ -195,7 +195,7 @@ describe('ServiceDeskRoot', () => {
.$nextTick()
.then(waitForPromises)
.then(() => {
- expect(wrapper.html()).toContain('Template was successfully saved.');
+ expect(wrapper.html()).toContain('Changes were successfully made.');
});
});
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 7fe310aa400..cb46751f66a 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
@@ -26,16 +26,16 @@ describe('ServiceDeskSetting', () => {
});
it('should see activation checkbox', () => {
- expect(wrapper.contains('#service-desk-checkbox')).toBe(true);
+ expect(wrapper.find('#service-desk-checkbox').exists()).toBe(true);
});
it('should see main panel with the email info', () => {
- expect(wrapper.contains('#incoming-email-describer')).toBe(true);
+ expect(wrapper.find('#incoming-email-describer').exists()).toBe(true);
});
it('should see loading spinner and not the incoming email', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
- expect(wrapper.contains('.incoming-email')).toBe(false);
+ expect(wrapper.find('.incoming-email').exists()).toBe(false);
});
});
});
@@ -78,7 +78,7 @@ describe('ServiceDeskSetting', () => {
});
it('renders a copy to clipboard button', () => {
- expect(wrapper.contains('.qa-clipboard-button')).toBe(true);
+ expect(wrapper.find('.qa-clipboard-button').exists()).toBe(true);
expect(wrapper.find('.qa-clipboard-button').element.dataset.clipboardText).toBe(
incomingEmail,
);
@@ -93,7 +93,7 @@ describe('ServiceDeskSetting', () => {
},
});
- expect(wrapper.contains('#service-desk-template-select')).toBe(true);
+ expect(wrapper.find('#service-desk-template-select').exists()).toBe(true);
});
it('renders a dropdown with a default value of ""', () => {
@@ -163,7 +163,7 @@ describe('ServiceDeskSetting', () => {
},
});
- expect(wrapper.find('button.btn-success').text()).toContain('Save template');
+ expect(wrapper.find('button.btn-success').text()).toContain('Save changes');
});
it('emits a save event with the chosen template when the save button is clicked', () => {
@@ -202,15 +202,15 @@ describe('ServiceDeskSetting', () => {
});
it('does not render email panel', () => {
- expect(wrapper.contains('#incoming-email-describer')).toBe(false);
+ expect(wrapper.find('#incoming-email-describer').exists()).toBe(false);
});
it('does not render template dropdown', () => {
- expect(wrapper.contains('#service-desk-template-select')).toBe(false);
+ expect(wrapper.find('#service-desk-template-select').exists()).toBe(false);
});
it('does not render template save button', () => {
- expect(wrapper.contains('button.btn-success')).toBe(false);
+ expect(wrapper.find('button.btn-success').exists()).toBe(false);
});
it('emits an event to turn on Service Desk when the toggle is clicked', () => {
diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js
index 1556f5b19dc..00b1d5cfbe2 100644
--- a/spec/frontend/ref/components/ref_selector_spec.js
+++ b/spec/frontend/ref/components/ref_selector_spec.js
@@ -2,9 +2,10 @@ import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import { GlLoadingIcon, GlSearchBoxByType, GlNewDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import { sprintf } from '~/locale';
+import { ENTER_KEY } from '~/lib/utils/keys';
import RefSelector from '~/ref/components/ref_selector.vue';
import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants';
import createStore from '~/ref/stores/';
@@ -83,16 +84,18 @@ describe('Ref selector component', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+ const findSearchBox = () => wrapper.find(GlSearchBoxByType);
+
const findBranchesSection = () => wrapper.find('[data-testid="branches-section"]');
- const findBranchDropdownItems = () => findBranchesSection().findAll(GlNewDropdownItem);
+ const findBranchDropdownItems = () => findBranchesSection().findAll(GlDropdownItem);
const findFirstBranchDropdownItem = () => findBranchDropdownItems().at(0);
const findTagsSection = () => wrapper.find('[data-testid="tags-section"]');
- const findTagDropdownItems = () => findTagsSection().findAll(GlNewDropdownItem);
+ const findTagDropdownItems = () => findTagsSection().findAll(GlDropdownItem);
const findFirstTagDropdownItem = () => findTagDropdownItems().at(0);
const findCommitsSection = () => wrapper.find('[data-testid="commits-section"]');
- const findCommitDropdownItems = () => findCommitsSection().findAll(GlNewDropdownItem);
+ const findCommitDropdownItems = () => findCommitsSection().findAll(GlDropdownItem);
const findFirstCommitDropdownItem = () => findCommitDropdownItems().at(0);
//
@@ -120,7 +123,7 @@ describe('Ref selector component', () => {
// Convenience methods
//
const updateQuery = newQuery => {
- wrapper.find(GlSearchBoxByType).vm.$emit('input', newQuery);
+ findSearchBox().vm.$emit('input', newQuery);
};
const selectFirstBranch = () => {
@@ -174,7 +177,7 @@ describe('Ref selector component', () => {
return waitForRequests();
});
- it('adds the provided ID to the GlNewDropdown instance', () => {
+ it('adds the provided ID to the GlDropdown instance', () => {
expect(wrapper.attributes().id).toBe(id);
});
});
@@ -244,6 +247,23 @@ describe('Ref selector component', () => {
});
});
+ describe('when the Enter is pressed', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests({ andClearMocks: true });
+ });
+
+ it('requeries the endpoints when Enter is pressed', () => {
+ findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+
+ return waitForRequests().then(() => {
+ expect(branchesApiCallSpy).toHaveBeenCalledTimes(1);
+ expect(tagsApiCallSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
describe('when no results are found', () => {
beforeEach(() => {
branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
diff --git a/spec/frontend/registry/explorer/components/delete_button_spec.js b/spec/frontend/registry/explorer/components/delete_button_spec.js
index bb0fe81117a..a79ca77a464 100644
--- a/spec/frontend/registry/explorer/components/delete_button_spec.js
+++ b/spec/frontend/registry/explorer/components/delete_button_spec.js
@@ -54,7 +54,6 @@ describe('delete_button', () => {
mountComponent({ disabled: true });
expect(findButton().attributes()).toMatchObject({
'aria-label': 'Foo title',
- category: 'secondary',
icon: 'remove',
title: 'Foo title',
variant: 'danger',
diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
index cb31efa428f..fc93e9094c9 100644
--- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
@@ -1,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import component from '~/registry/explorer/components/details_page/details_header.vue';
import { DETAILS_PAGE_TITLE } from '~/registry/explorer/constants';
@@ -11,6 +12,7 @@ describe('Details Header', () => {
propsData,
stubs: {
GlSprintf,
+ TitleArea,
},
});
};
diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js
index a21facefc97..ef22979ca7d 100644
--- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js
@@ -6,7 +6,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import component from '~/registry/explorer/components/details_page/tags_list_row.vue';
import DeleteButton from '~/registry/explorer/components/delete_button.vue';
-import DetailsRow from '~/registry/shared/components/details_row.vue';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
import {
REMOVE_TAG_BUTTON_TITLE,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js
index 1f560753476..401202026bb 100644
--- a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js
@@ -115,7 +115,6 @@ describe('Tags List', () => {
// The list has only two tags and for some reasons .at(-1) does not work
expect(rows.at(1).attributes()).toMatchObject({
- last: 'true',
isdesktop: 'true',
});
});
diff --git a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js
index b0291de5f3c..b4471ab8122 100644
--- a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js
@@ -1,10 +1,10 @@
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
-import { GlDeprecatedDropdown, GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
+import { GlDeprecatedDropdown } from '@gitlab/ui';
import Tracking from '~/tracking';
import * as getters from '~/registry/explorer/stores/getters';
import QuickstartDropdown from '~/registry/explorer/components/list_page/cli_commands.vue';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import {
QUICK_START,
@@ -24,7 +24,7 @@ describe('cli_commands', () => {
let store;
const findDropdownButton = () => wrapper.find(GlDeprecatedDropdown);
- const findFormGroups = () => wrapper.findAll(GlFormGroup);
+ const findCodeInstruction = () => wrapper.findAll(CodeInstruction);
const mountComponent = () => {
store = new Vuex.Store({
@@ -67,54 +67,29 @@ describe('cli_commands', () => {
});
describe.each`
- index | id | labelText | titleText | getter | trackedEvent
- ${0} | ${'docker-login-btn'} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${'dockerLoginCommand'} | ${'click_copy_login'}
- ${1} | ${'docker-build-btn'} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${'dockerBuildCommand'} | ${'click_copy_build'}
- ${2} | ${'docker-push-btn'} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${'dockerPushCommand'} | ${'click_copy_push'}
- `('form group at $index', ({ index, id, labelText, titleText, getter, trackedEvent }) => {
- let formGroup;
-
- const findFormInputGroup = parent => parent.find(GlFormInputGroup);
- const findClipboardButton = parent => parent.find(ClipboardButton);
+ index | labelText | titleText | getter | trackedEvent
+ ${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${'dockerLoginCommand'} | ${'click_copy_login'}
+ ${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${'dockerBuildCommand'} | ${'click_copy_build'}
+ ${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${'dockerPushCommand'} | ${'click_copy_push'}
+ `('code instructions at $index', ({ index, labelText, titleText, getter, trackedEvent }) => {
+ let codeInstruction;
beforeEach(() => {
- formGroup = findFormGroups().at(index);
+ codeInstruction = findCodeInstruction().at(index);
});
it('exists', () => {
- expect(formGroup.exists()).toBe(true);
- });
-
- it(`has a label ${labelText}`, () => {
- expect(formGroup.text()).toBe(labelText);
- });
-
- it(`contains a form input group with ${id} id and with value equal to ${getter} getter`, () => {
- const formInputGroup = findFormInputGroup(formGroup);
- expect(formInputGroup.exists()).toBe(true);
- expect(formInputGroup.attributes('id')).toBe(id);
- expect(formInputGroup.props('value')).toBe(store.getters[getter]);
- });
-
- it(`contains a clipboard button with title of ${titleText} and text equal to ${getter} getter`, () => {
- const clipBoardButton = findClipboardButton(formGroup);
- expect(clipBoardButton.exists()).toBe(true);
- expect(clipBoardButton.props('title')).toBe(titleText);
- expect(clipBoardButton.props('text')).toBe(store.getters[getter]);
+ expect(codeInstruction.exists()).toBe(true);
});
- it('clipboard button tracks click event', () => {
- const clipBoardButton = findClipboardButton(formGroup);
- clipBoardButton.trigger('click');
- /* This expect to be called with first argument undefined so that
- * the function internally can default to document.body.dataset.page
- * https://docs.gitlab.com/ee/telemetry/frontend.html#tracking-within-vue-components
- */
- expect(Tracking.event).toHaveBeenCalledWith(
- undefined,
- trackedEvent,
- expect.objectContaining({ label: 'quickstart_dropdown' }),
- );
+ it(`has the correct props`, () => {
+ expect(codeInstruction.props()).toMatchObject({
+ label: labelText,
+ instruction: store.getters[getter],
+ copyText: titleText,
+ trackingAction: trackedEvent,
+ trackingLabel: 'quickstart_dropdown',
+ });
});
});
});
diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
index aaeaaf00748..c5b4b3fa5d8 100644
--- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
@@ -3,7 +3,7 @@ import { GlIcon, GlSprintf } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Component from '~/registry/explorer/components/list_page/image_list_row.vue';
-import ListItem from '~/registry/explorer/components/list_item.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
import DeleteButton from '~/registry/explorer/components/delete_button.vue';
import {
ROW_SCHEDULED_FOR_DELETION,
diff --git a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
index 7484fccbea7..7a27f8fa431 100644
--- a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
@@ -1,12 +1,12 @@
import { shallowMount } from '@vue/test-utils';
import { GlSprintf, GlLink } from '@gitlab/ui';
import Component from '~/registry/explorer/components/list_page/registry_header.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import {
CONTAINER_REGISTRY_TITLE,
LIST_INTRO_TEXT,
EXPIRATION_POLICY_DISABLED_MESSAGE,
EXPIRATION_POLICY_DISABLED_TEXT,
- EXPIRATION_POLICY_WILL_RUN_IN,
} from '~/registry/explorer/constants';
jest.mock('~/lib/utils/datetime_utility', () => ({
@@ -17,12 +17,10 @@ jest.mock('~/lib/utils/datetime_utility', () => ({
describe('registry_header', () => {
let wrapper;
- const findHeader = () => wrapper.find('[data-testid="header"]');
- const findTitle = () => wrapper.find('[data-testid="title"]');
+ const findTitleArea = () => wrapper.find(TitleArea);
const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]');
const findInfoArea = () => wrapper.find('[data-testid="info-area"]');
const findIntroText = () => wrapper.find('[data-testid="default-intro"]');
- const findSubHeader = () => wrapper.find('[data-testid="subheader"]');
const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]');
const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]');
const findDisabledExpirationPolicyMessage = () =>
@@ -32,10 +30,12 @@ describe('registry_header', () => {
wrapper = shallowMount(Component, {
stubs: {
GlSprintf,
+ TitleArea,
},
propsData,
slots,
});
+ return wrapper.vm.$nextTick();
};
afterEach(() => {
@@ -44,90 +44,80 @@ describe('registry_header', () => {
});
describe('header', () => {
- it('exists', () => {
+ it('has a title', () => {
mountComponent();
- expect(findHeader().exists()).toBe(true);
- });
- it('contains the title of the page', () => {
- mountComponent();
- const title = findTitle();
- expect(title.exists()).toBe(true);
- expect(title.text()).toBe(CONTAINER_REGISTRY_TITLE);
+ expect(findTitleArea().props('title')).toBe(CONTAINER_REGISTRY_TITLE);
});
it('has a commands slot', () => {
- mountComponent(null, { commands: 'baz' });
+ mountComponent(null, { commands: '<div data-testid="commands-slot">baz</div>' });
+
expect(findCommandsSlot().text()).toBe('baz');
});
- });
- describe('subheader', () => {
- describe('when there are no images', () => {
- it('is hidden ', () => {
- mountComponent();
- expect(findSubHeader().exists()).toBe(false);
- });
- });
+ describe('sub header parts', () => {
+ describe('images count', () => {
+ it('exists', async () => {
+ await mountComponent({ imagesCount: 1 });
- describe('when there are images', () => {
- it('is visible', () => {
- mountComponent({ imagesCount: 1 });
- expect(findSubHeader().exists()).toBe(true);
- });
+ expect(findImagesCountSubHeader().exists()).toBe(true);
+ });
+
+ it('when there is one image', async () => {
+ await mountComponent({ imagesCount: 1 });
- describe('sub header parts', () => {
- describe('images count', () => {
- it('exists', () => {
- mountComponent({ imagesCount: 1 });
- expect(findImagesCountSubHeader().exists()).toBe(true);
+ expect(findImagesCountSubHeader().props()).toMatchObject({
+ text: '1 Image repository',
+ icon: 'container-image',
});
+ });
+
+ it('when there is more than one image', async () => {
+ await mountComponent({ imagesCount: 3 });
+
+ expect(findImagesCountSubHeader().props('text')).toBe('3 Image repositories');
+ });
+ });
- it('when there is one image', () => {
- mountComponent({ imagesCount: 1 });
- expect(findImagesCountSubHeader().text()).toMatchInterpolatedText('1 Image repository');
+ describe('expiration policy', () => {
+ it('when is disabled', async () => {
+ await mountComponent({
+ expirationPolicy: { enabled: false },
+ expirationPolicyHelpPagePath: 'foo',
+ imagesCount: 1,
});
- it('when there is more than one image', () => {
- mountComponent({ imagesCount: 3 });
- expect(findImagesCountSubHeader().text()).toMatchInterpolatedText(
- '3 Image repositories',
- );
+ const text = findExpirationPolicySubHeader();
+ expect(text.exists()).toBe(true);
+ expect(text.props()).toMatchObject({
+ text: EXPIRATION_POLICY_DISABLED_TEXT,
+ icon: 'expire',
+ size: 'xl',
});
});
- describe('expiration policy', () => {
- it('when is disabled', () => {
- mountComponent({
- expirationPolicy: { enabled: false },
- expirationPolicyHelpPagePath: 'foo',
- imagesCount: 1,
- });
- const text = findExpirationPolicySubHeader();
- expect(text.exists()).toBe(true);
- expect(text.text()).toMatchInterpolatedText(EXPIRATION_POLICY_DISABLED_TEXT);
+ it('when is enabled', async () => {
+ await mountComponent({
+ expirationPolicy: { enabled: true },
+ expirationPolicyHelpPagePath: 'foo',
+ imagesCount: 1,
});
- it('when is enabled', () => {
- mountComponent({
- expirationPolicy: { enabled: true },
- expirationPolicyHelpPagePath: 'foo',
- imagesCount: 1,
- });
- const text = findExpirationPolicySubHeader();
- expect(text.exists()).toBe(true);
- expect(text.text()).toMatchInterpolatedText(EXPIRATION_POLICY_WILL_RUN_IN);
- });
- it('when the expiration policy is completely disabled', () => {
- mountComponent({
- expirationPolicy: { enabled: true },
- expirationPolicyHelpPagePath: 'foo',
- imagesCount: 1,
- hideExpirationPolicyData: true,
- });
- const text = findExpirationPolicySubHeader();
- expect(text.exists()).toBe(false);
+ const text = findExpirationPolicySubHeader();
+ expect(text.exists()).toBe(true);
+ expect(text.props('text')).toBe('Expiration policy will run in ');
+ });
+ it('when the expiration policy is completely disabled', async () => {
+ await mountComponent({
+ expirationPolicy: { enabled: true },
+ expirationPolicyHelpPagePath: 'foo',
+ imagesCount: 1,
+ hideExpirationPolicyData: true,
});
+
+ const text = findExpirationPolicySubHeader();
+ expect(text.exists()).toBe(false);
});
});
});
@@ -136,12 +126,13 @@ describe('registry_header', () => {
describe('info area', () => {
it('exists', () => {
mountComponent();
+
expect(findInfoArea().exists()).toBe(true);
});
describe('default message', () => {
beforeEach(() => {
- mountComponent({ helpPagePath: 'bar' });
+ return mountComponent({ helpPagePath: 'bar' });
});
it('exists', () => {
@@ -165,6 +156,7 @@ describe('registry_header', () => {
describe('when there are no images', () => {
it('is hidden', () => {
mountComponent();
+
expect(findDisabledExpirationPolicyMessage().exists()).toBe(false);
});
});
@@ -172,7 +164,7 @@ describe('registry_header', () => {
describe('when there are images', () => {
describe('when expiration policy is disabled', () => {
beforeEach(() => {
- mountComponent({
+ return mountComponent({
expirationPolicy: { enabled: false },
expirationPolicyHelpPagePath: 'foo',
imagesCount: 1,
@@ -202,6 +194,7 @@ describe('registry_header', () => {
expirationPolicy: { enabled: true },
imagesCount: 1,
});
+
expect(findDisabledExpirationPolicyMessage().exists()).toBe(false);
});
});
@@ -212,6 +205,7 @@ describe('registry_header', () => {
imagesCount: 1,
hideExpirationPolicyData: true,
});
+
expect(findDisabledExpirationPolicyMessage().exists()).toBe(false);
});
});
diff --git a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js
index f04585a6ff4..b906e44a4f7 100644
--- a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js
+++ b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js
@@ -117,7 +117,7 @@ describe('Registry Breadcrumb', () => {
});
it('has the same tag as the last children of the crumbs', () => {
- expect(findLastCrumb().is(lastChildren.tagName)).toBe(true);
+ expect(findLastCrumb().element.tagName).toBe(lastChildren.tagName.toUpperCase());
});
it('has the same classes as the last children of the crumbs', () => {
diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js
index b4e46fda2c4..b24422adb03 100644
--- a/spec/frontend/registry/explorer/pages/list_spec.js
+++ b/spec/frontend/registry/explorer/pages/list_spec.js
@@ -8,6 +8,7 @@ import GroupEmptyState from '~/registry/explorer/components/list_page/group_empt
import ProjectEmptyState from '~/registry/explorer/components/list_page/project_empty_state.vue';
import RegistryHeader from '~/registry/explorer/components/list_page/registry_header.vue';
import ImageList from '~/registry/explorer/components/list_page/image_list.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { createStore } from '~/registry/explorer/stores/';
import {
SET_MAIN_LOADING,
@@ -54,6 +55,7 @@ describe('List Page', () => {
GlEmptyState,
GlSprintf,
RegistryHeader,
+ TitleArea,
},
mocks: {
$toast,
diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js
index 8f95fce2867..b6c0ee67757 100644
--- a/spec/frontend/registry/explorer/stubs.js
+++ b/spec/frontend/registry/explorer/stubs.js
@@ -1,5 +1,5 @@
import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue';
-import RealListItem from '~/registry/explorer/components/list_item.vue';
+import RealListItem from '~/vue_shared/components/registry/list_item.vue';
export const GlModal = {
template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
diff --git a/spec/frontend/registry/shared/mocks.js b/spec/frontend/registry/shared/mocks.js
index e33d06e7499..fdef38b6f10 100644
--- a/spec/frontend/registry/shared/mocks.js
+++ b/spec/frontend/registry/shared/mocks.js
@@ -1,4 +1,3 @@
-// eslint-disable-next-line import/prefer-default-export
export const $toast = {
show: jest.fn(),
};
diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap
new file mode 100644
index 00000000000..f56e296d106
--- /dev/null
+++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap
@@ -0,0 +1,113 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`releases/util.js convertGraphQLResponse matches snapshot 1`] = `
+Object {
+ "data": Array [
+ Object {
+ "_links": Object {
+ "editUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit",
+ "issuesUrl": null,
+ "mergeRequestsUrl": null,
+ "self": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10",
+ "selfUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10",
+ },
+ "assets": Object {
+ "count": 7,
+ "links": Array [
+ Object {
+ "directAssetUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook",
+ "external": true,
+ "id": "gid://gitlab/Releases::Link/69",
+ "linkType": "other",
+ "name": "An example link",
+ "url": "https://example.com/link",
+ },
+ Object {
+ "directAssetUrl": "https://example.com/package",
+ "external": true,
+ "id": "gid://gitlab/Releases::Link/68",
+ "linkType": "package",
+ "name": "An example package link",
+ "url": "https://example.com/package",
+ },
+ Object {
+ "directAssetUrl": "https://example.com/image",
+ "external": true,
+ "id": "gid://gitlab/Releases::Link/67",
+ "linkType": "image",
+ "name": "An example image",
+ "url": "https://example.com/image",
+ },
+ ],
+ "sources": Array [
+ Object {
+ "format": "zip",
+ "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip",
+ },
+ Object {
+ "format": "tar.gz",
+ "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz",
+ },
+ Object {
+ "format": "tar.bz2",
+ "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2",
+ },
+ Object {
+ "format": "tar",
+ "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar",
+ },
+ ],
+ },
+ "author": Object {
+ "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png",
+ "username": "root",
+ "webUrl": "http://0.0.0.0:3000/root",
+ },
+ "commit": Object {
+ "shortId": "92e7ea2e",
+ "title": "Testing a change.",
+ },
+ "commitPath": "http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7",
+ "descriptionHtml": "<p data-sourcepos=\\"1:1-1:24\\" dir=\\"auto\\">This is version <strong>1.0</strong>!</p>",
+ "evidences": Array [
+ Object {
+ "collectedAt": "2020-08-21T20:15:19Z",
+ "filepath": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json",
+ "sha": "22bde8e8b93d870a29ddc339287a1fbb598f45d1396d",
+ },
+ ],
+ "milestones": Array [
+ Object {
+ "description": "",
+ "id": "gid://gitlab/Milestone/60",
+ "issueStats": Object {
+ "closed": 0,
+ "total": 0,
+ },
+ "stats": undefined,
+ "title": "12.4",
+ "webPath": undefined,
+ "webUrl": "/root/release-test/-/milestones/2",
+ },
+ Object {
+ "description": "Milestone 12.3",
+ "id": "gid://gitlab/Milestone/59",
+ "issueStats": Object {
+ "closed": 1,
+ "total": 2,
+ },
+ "stats": undefined,
+ "title": "12.3",
+ "webPath": undefined,
+ "webUrl": "/root/release-test/-/milestones/1",
+ },
+ ],
+ "name": "Release 1.0",
+ "releasedAt": "2020-08-21T20:15:18Z",
+ "tagName": "v5.10",
+ "tagPath": "/root/release-test/-/tags/v5.10",
+ "upcomingRelease": false,
+ },
+ ],
+}
+`;
diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js
index 8eafe07cb2f..bcb87509cc3 100644
--- a/spec/frontend/releases/components/app_index_spec.js
+++ b/spec/frontend/releases/components/app_index_spec.js
@@ -1,12 +1,11 @@
import { range as rge } from 'lodash';
-import Vue from 'vue';
-import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
-import app from '~/releases/components/app_index.vue';
+import ReleasesApp from '~/releases/components/app_index.vue';
import createStore from '~/releases/stores';
-import listModule from '~/releases/stores/modules/list';
+import createListModule from '~/releases/stores/modules/list';
import api from '~/api';
-import { resetStore } from '../stores/modules/list/helpers';
import {
pageInfoHeadersWithoutPagination,
pageInfoHeadersWithPagination,
@@ -14,30 +13,67 @@ import {
releases,
} from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
describe('Releases App ', () => {
- const Component = Vue.extend(app);
- let store;
- let vm;
- let releasesPagination;
+ let wrapper;
+ let fetchReleaseSpy;
+
+ const releasesPagination = rge(21).map(index => ({
+ ...convertObjectPropsToCamelCase(release, { deep: true }),
+ tagName: `${index}.00`,
+ }));
- const props = {
+ const defaultInitialState = {
projectId: 'gitlab-ce',
+ projectPath: 'gitlab-org/gitlab-ce',
documentationPath: 'help/releases',
illustrationPath: 'illustration/path',
};
- beforeEach(() => {
- store = createStore({ modules: { list: listModule } });
- releasesPagination = rge(21).map(index => ({
- ...convertObjectPropsToCamelCase(release, { deep: true }),
- tagName: `${index}.00`,
- }));
- });
+ const createComponent = (stateUpdates = {}) => {
+ const listModule = createListModule({
+ ...defaultInitialState,
+ ...stateUpdates,
+ });
+
+ fetchReleaseSpy = jest.spyOn(listModule.actions, 'fetchReleases');
+
+ const store = createStore({
+ modules: { list: listModule },
+ featureFlags: {
+ graphqlReleaseData: true,
+ graphqlReleasesPage: false,
+ graphqlMilestoneStats: true,
+ },
+ });
+
+ wrapper = shallowMount(ReleasesApp, {
+ store,
+ localVue,
+ });
+ };
afterEach(() => {
- resetStore(store);
- vm.$destroy();
+ wrapper.destroy();
+ });
+
+ describe('on startup', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(api, 'releases')
+ .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
+
+ createComponent();
+ });
+
+ it('calls fetchRelease with the page parameter', () => {
+ expect(fetchReleaseSpy).toHaveBeenCalledTimes(1);
+ expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { page: null });
+ });
});
describe('while loading', () => {
@@ -47,16 +83,15 @@ describe('Releases App ', () => {
// Need to defer the return value here to the next stack,
// otherwise the loading state disappears before our test even starts.
.mockImplementation(() => waitForPromises().then(() => ({ data: [], headers: {} })));
- vm = mountComponentWithStore(Component, { props, store });
+
+ createComponent();
});
it('renders loading icon', () => {
- expect(vm.$el.querySelector('.js-loading')).not.toBeNull();
- expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
- expect(vm.$el.querySelector('.js-success-state')).toBeNull();
- expect(vm.$el.querySelector('.gl-pagination')).toBeNull();
-
- return waitForPromises();
+ expect(wrapper.contains('.js-loading')).toBe(true);
+ expect(wrapper.contains('.js-empty-state')).toBe(false);
+ expect(wrapper.contains('.js-success-state')).toBe(false);
+ expect(wrapper.contains(TablePagination)).toBe(false);
});
});
@@ -65,14 +100,15 @@ describe('Releases App ', () => {
jest
.spyOn(api, 'releases')
.mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
- vm = mountComponentWithStore(Component, { props, store });
+
+ createComponent();
});
it('renders success state', () => {
- expect(vm.$el.querySelector('.js-loading')).toBeNull();
- expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
- expect(vm.$el.querySelector('.js-success-state')).not.toBeNull();
- expect(vm.$el.querySelector('.gl-pagination')).toBeNull();
+ expect(wrapper.contains('.js-loading')).toBe(false);
+ expect(wrapper.contains('.js-empty-state')).toBe(false);
+ expect(wrapper.contains('.js-success-state')).toBe(true);
+ expect(wrapper.contains(TablePagination)).toBe(true);
});
});
@@ -81,69 +117,60 @@ describe('Releases App ', () => {
jest
.spyOn(api, 'releases')
.mockResolvedValue({ data: releasesPagination, headers: pageInfoHeadersWithPagination });
- vm = mountComponentWithStore(Component, { props, store });
+
+ createComponent();
});
it('renders success state', () => {
- expect(vm.$el.querySelector('.js-loading')).toBeNull();
- expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
- expect(vm.$el.querySelector('.js-success-state')).not.toBeNull();
- expect(vm.$el.querySelector('.gl-pagination')).not.toBeNull();
+ expect(wrapper.contains('.js-loading')).toBe(false);
+ expect(wrapper.contains('.js-empty-state')).toBe(false);
+ expect(wrapper.contains('.js-success-state')).toBe(true);
+ expect(wrapper.contains(TablePagination)).toBe(true);
});
});
describe('with empty request', () => {
beforeEach(() => {
jest.spyOn(api, 'releases').mockResolvedValue({ data: [], headers: {} });
- vm = mountComponentWithStore(Component, { props, store });
+
+ createComponent();
});
it('renders empty state', () => {
- expect(vm.$el.querySelector('.js-loading')).toBeNull();
- expect(vm.$el.querySelector('.js-empty-state')).not.toBeNull();
- expect(vm.$el.querySelector('.js-success-state')).toBeNull();
- expect(vm.$el.querySelector('.gl-pagination')).toBeNull();
+ expect(wrapper.contains('.js-loading')).toBe(false);
+ expect(wrapper.contains('.js-empty-state')).toBe(true);
+ expect(wrapper.contains('.js-success-state')).toBe(false);
});
});
describe('"New release" button', () => {
- const findNewReleaseButton = () => vm.$el.querySelector('.js-new-release-btn');
+ const findNewReleaseButton = () => wrapper.find('.js-new-release-btn');
beforeEach(() => {
jest.spyOn(api, 'releases').mockResolvedValue({ data: [], headers: {} });
});
- const factory = additionalProps => {
- vm = mountComponentWithStore(Component, {
- props: {
- ...props,
- ...additionalProps,
- },
- store,
- });
- };
-
describe('when the user is allowed to create a new Release', () => {
const newReleasePath = 'path/to/new/release';
beforeEach(() => {
- factory({ newReleasePath });
+ createComponent({ ...defaultInitialState, newReleasePath });
});
it('renders the "New release" button', () => {
- expect(findNewReleaseButton()).not.toBeNull();
+ expect(findNewReleaseButton().exists()).toBe(true);
});
it('renders the "New release" button with the correct href', () => {
- expect(findNewReleaseButton().getAttribute('href')).toBe(newReleasePath);
+ expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath);
});
});
describe('when the user is not allowed to create a new Release', () => {
- beforeEach(() => factory());
+ beforeEach(() => createComponent());
it('does not render the "New release" button', () => {
- expect(findNewReleaseButton()).toBeNull();
+ expect(findNewReleaseButton().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js
index e757fe98661..502a1053663 100644
--- a/spec/frontend/releases/components/app_show_spec.js
+++ b/spec/frontend/releases/components/app_show_spec.js
@@ -1,6 +1,6 @@
import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import ReleaseShowApp from '~/releases/components/app_show.vue';
import { release as originalRelease } from '../mock_data';
import ReleaseBlock from '~/releases/components/release_block.vue';
diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js
index 727d593d851..582c0b32716 100644
--- a/spec/frontend/releases/components/asset_links_form_spec.js
+++ b/spec/frontend/releases/components/asset_links_form_spec.js
@@ -115,14 +115,10 @@ describe('Release edit component', () => {
const expectStoreMethodToBeCalled = () => {
expect(actions.updateAssetLinkUrl).toHaveBeenCalledTimes(1);
- expect(actions.updateAssetLinkUrl).toHaveBeenCalledWith(
- expect.anything(),
- {
- linkIdToUpdate,
- newUrl,
- },
- undefined,
- );
+ expect(actions.updateAssetLinkUrl).toHaveBeenCalledWith(expect.anything(), {
+ linkIdToUpdate,
+ newUrl,
+ });
};
it('calls the "updateAssetLinkUrl" store method when text is entered into the "URL" input field', () => {
@@ -177,14 +173,10 @@ describe('Release edit component', () => {
const expectStoreMethodToBeCalled = () => {
expect(actions.updateAssetLinkName).toHaveBeenCalledTimes(1);
- expect(actions.updateAssetLinkName).toHaveBeenCalledWith(
- expect.anything(),
- {
- linkIdToUpdate,
- newName,
- },
- undefined,
- );
+ expect(actions.updateAssetLinkName).toHaveBeenCalledWith(expect.anything(), {
+ linkIdToUpdate,
+ newName,
+ });
};
it('calls the "updateAssetLinkName" store method when text is entered into the "Link title" input field', () => {
@@ -225,14 +217,10 @@ describe('Release edit component', () => {
wrapper.find({ ref: 'typeSelect' }).vm.$emit('change', newType);
expect(actions.updateAssetLinkType).toHaveBeenCalledTimes(1);
- expect(actions.updateAssetLinkType).toHaveBeenCalledWith(
- expect.anything(),
- {
- linkIdToUpdate,
- newType,
- },
- undefined,
- );
+ expect(actions.updateAssetLinkType).toHaveBeenCalledWith(expect.anything(), {
+ linkIdToUpdate,
+ newType,
+ });
});
it('selects the default asset type if no type was provided by the backend', () => {
diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js
index 5e84290716c..3453ecbf8ab 100644
--- a/spec/frontend/releases/components/release_block_assets_spec.js
+++ b/spec/frontend/releases/components/release_block_assets_spec.js
@@ -128,7 +128,7 @@ describe('Release block assets', () => {
describe('external vs internal links', () => {
const containsExternalSourceIndicator = () =>
- wrapper.contains('[data-testid="external-link-indicator"]');
+ wrapper.find('[data-testid="external-link-indicator"]').exists();
describe('when a link is external', () => {
beforeEach(() => {
diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js
index c066bfbf020..bde01cc0e00 100644
--- a/spec/frontend/releases/components/release_block_footer_spec.js
+++ b/spec/frontend/releases/components/release_block_footer_spec.js
@@ -1,9 +1,8 @@
import { mount } from '@vue/test-utils';
-import { GlLink } from '@gitlab/ui';
+import { GlLink, GlIcon } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import { cloneDeep } from 'lodash';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
-import Icon from '~/vue_shared/components/icon.vue';
import { release as originalRelease } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
@@ -56,7 +55,7 @@ describe('Release block footer', () => {
beforeEach(() => factory());
it('renders the commit icon', () => {
- const commitIcon = commitInfoSection().find(Icon);
+ const commitIcon = commitInfoSection().find(GlIcon);
expect(commitIcon.exists()).toBe(true);
expect(commitIcon.props('name')).toBe('commit');
@@ -71,7 +70,7 @@ describe('Release block footer', () => {
});
it('renders the tag icon', () => {
- const commitIcon = tagInfoSection().find(Icon);
+ const commitIcon = tagInfoSection().find(GlIcon);
expect(commitIcon.exists()).toBe(true);
expect(commitIcon.props('name')).toBe('tag');
diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js
index 19119d99f3c..a7f1388664b 100644
--- a/spec/frontend/releases/components/release_block_spec.js
+++ b/spec/frontend/releases/components/release_block_spec.js
@@ -1,11 +1,11 @@
import $ from 'jquery';
import { mount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import EvidenceBlock from '~/releases/components/evidence_block.vue';
import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { release as originalRelease } from '../mock_data';
-import Icon from '~/vue_shared/components/icon.vue';
import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
import * as urlUtility from '~/lib/utils/url_utility';
@@ -247,7 +247,7 @@ describe('Release block', () => {
it('renders the milestone icon', () => {
expect(
milestoneListLabel()
- .find(Icon)
+ .find(GlIcon)
.exists(),
).toBe(true);
});
diff --git a/spec/frontend/releases/components/releases_pagination_graphql_spec.js b/spec/frontend/releases/components/releases_pagination_graphql_spec.js
new file mode 100644
index 00000000000..b01a28eb6c3
--- /dev/null
+++ b/spec/frontend/releases/components/releases_pagination_graphql_spec.js
@@ -0,0 +1,175 @@
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
+import createStore from '~/releases/stores';
+import createListModule from '~/releases/stores/modules/list';
+import ReleasesPaginationGraphql from '~/releases/components/releases_pagination_graphql.vue';
+import { historyPushState } from '~/lib/utils/common_utils';
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ ...jest.requireActual('~/lib/utils/common_utils'),
+ historyPushState: jest.fn(),
+}));
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('~/releases/components/releases_pagination_graphql.vue', () => {
+ let wrapper;
+ let listModule;
+
+ const cursors = {
+ startCursor: 'startCursor',
+ endCursor: 'endCursor',
+ };
+
+ const projectPath = 'my/project';
+
+ const createComponent = pageInfo => {
+ listModule = createListModule({ projectPath });
+
+ listModule.state.graphQlPageInfo = pageInfo;
+
+ listModule.actions.fetchReleasesGraphQl = jest.fn();
+
+ wrapper = mount(ReleasesPaginationGraphql, {
+ store: createStore({
+ modules: {
+ list: listModule,
+ },
+ featureFlags: {},
+ }),
+ localVue,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findPrevButton = () => wrapper.find('[data-testid="prevButton"]');
+ const findNextButton = () => wrapper.find('[data-testid="nextButton"]');
+
+ const expectDisabledPrev = () => {
+ expect(findPrevButton().attributes().disabled).toBe('disabled');
+ };
+ const expectEnabledPrev = () => {
+ expect(findPrevButton().attributes().disabled).toBe(undefined);
+ };
+ const expectDisabledNext = () => {
+ expect(findNextButton().attributes().disabled).toBe('disabled');
+ };
+ const expectEnabledNext = () => {
+ expect(findNextButton().attributes().disabled).toBe(undefined);
+ };
+
+ describe('when there is only one page of results', () => {
+ beforeEach(() => {
+ createComponent({
+ hasPreviousPage: false,
+ hasNextPage: false,
+ });
+ });
+
+ it('does not render anything', () => {
+ expect(wrapper.isEmpty()).toBe(true);
+ });
+ });
+
+ describe('when there is a next page, but not a previous page', () => {
+ beforeEach(() => {
+ createComponent({
+ hasPreviousPage: false,
+ hasNextPage: true,
+ });
+ });
+
+ it('renders a disabled "Prev" button', () => {
+ expectDisabledPrev();
+ });
+
+ it('renders an enabled "Next" button', () => {
+ expectEnabledNext();
+ });
+ });
+
+ describe('when there is a previous page, but not a next page', () => {
+ beforeEach(() => {
+ createComponent({
+ hasPreviousPage: true,
+ hasNextPage: false,
+ });
+ });
+
+ it('renders a enabled "Prev" button', () => {
+ expectEnabledPrev();
+ });
+
+ it('renders an disabled "Next" button', () => {
+ expectDisabledNext();
+ });
+ });
+
+ describe('when there is both a previous page and a next page', () => {
+ beforeEach(() => {
+ createComponent({
+ hasPreviousPage: true,
+ hasNextPage: true,
+ });
+ });
+
+ it('renders a enabled "Prev" button', () => {
+ expectEnabledPrev();
+ });
+
+ it('renders an enabled "Next" button', () => {
+ expectEnabledNext();
+ });
+ });
+
+ describe('button behavior', () => {
+ beforeEach(() => {
+ createComponent({
+ hasPreviousPage: true,
+ hasNextPage: true,
+ ...cursors,
+ });
+ });
+
+ describe('next button behavior', () => {
+ beforeEach(() => {
+ findNextButton().trigger('click');
+ });
+
+ it('calls fetchReleasesGraphQl with the correct after cursor', () => {
+ expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([
+ [expect.anything(), { after: cursors.endCursor }],
+ ]);
+ });
+
+ it('calls historyPushState with the new URL', () => {
+ expect(historyPushState.mock.calls).toEqual([
+ [expect.stringContaining(`?after=${cursors.endCursor}`)],
+ ]);
+ });
+ });
+
+ describe('previous button behavior', () => {
+ beforeEach(() => {
+ findPrevButton().trigger('click');
+ });
+
+ it('calls fetchReleasesGraphQl with the correct before cursor', () => {
+ expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([
+ [expect.anything(), { before: cursors.startCursor }],
+ ]);
+ });
+
+ it('calls historyPushState with the new URL', () => {
+ expect(historyPushState.mock.calls).toEqual([
+ [expect.stringContaining(`?before=${cursors.startCursor}`)],
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/releases/components/releases_pagination_rest_spec.js b/spec/frontend/releases/components/releases_pagination_rest_spec.js
new file mode 100644
index 00000000000..4fd3e085fc9
--- /dev/null
+++ b/spec/frontend/releases/components/releases_pagination_rest_spec.js
@@ -0,0 +1,72 @@
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { GlPagination } from '@gitlab/ui';
+import ReleasesPaginationRest from '~/releases/components/releases_pagination_rest.vue';
+import createStore from '~/releases/stores';
+import createListModule from '~/releases/stores/modules/list';
+import * as commonUtils from '~/lib/utils/common_utils';
+
+commonUtils.historyPushState = jest.fn();
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('~/releases/components/releases_pagination_rest.vue', () => {
+ let wrapper;
+ let listModule;
+
+ const projectId = 19;
+
+ const createComponent = pageInfo => {
+ listModule = createListModule({ projectId });
+
+ listModule.state.pageInfo = pageInfo;
+
+ listModule.actions.fetchReleasesRest = jest.fn();
+
+ wrapper = mount(ReleasesPaginationRest, {
+ store: createStore({
+ modules: {
+ list: listModule,
+ },
+ featureFlags: {},
+ }),
+ localVue,
+ });
+ };
+
+ const findGlPagination = () => wrapper.find(GlPagination);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when a page number is clicked', () => {
+ const newPage = 2;
+
+ beforeEach(() => {
+ createComponent({
+ perPage: 20,
+ page: 1,
+ total: 40,
+ totalPages: 2,
+ nextPage: 2,
+ });
+
+ findGlPagination().vm.$emit('input', newPage);
+ });
+
+ it('calls fetchReleasesRest with the correct page', () => {
+ expect(listModule.actions.fetchReleasesRest.mock.calls).toEqual([
+ [expect.anything(), { page: newPage }],
+ ]);
+ });
+
+ it('calls historyPushState with the new URL', () => {
+ expect(commonUtils.historyPushState.mock.calls).toEqual([
+ [expect.stringContaining(`?page=${newPage}`)],
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js
new file mode 100644
index 00000000000..2466fb53a68
--- /dev/null
+++ b/spec/frontend/releases/components/releases_pagination_spec.js
@@ -0,0 +1,52 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import ReleasesPagination from '~/releases/components/releases_pagination.vue';
+import ReleasesPaginationGraphql from '~/releases/components/releases_pagination_graphql.vue';
+import ReleasesPaginationRest from '~/releases/components/releases_pagination_rest.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('~/releases/components/releases_pagination.vue', () => {
+ let wrapper;
+
+ const createComponent = useGraphQLEndpoint => {
+ const store = new Vuex.Store({
+ getters: {
+ useGraphQLEndpoint: () => useGraphQLEndpoint,
+ },
+ });
+
+ wrapper = shallowMount(ReleasesPagination, { store, localVue });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findRestPagination = () => wrapper.find(ReleasesPaginationRest);
+ const findGraphQlPagination = () => wrapper.find(ReleasesPaginationGraphql);
+
+ describe('when one of necessary feature flags is disabled', () => {
+ beforeEach(() => {
+ createComponent(false);
+ });
+
+ it('renders the REST pagination component', () => {
+ expect(findRestPagination().exists()).toBe(true);
+ expect(findGraphQlPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('when all the necessary feature flags are enabled', () => {
+ beforeEach(() => {
+ createComponent(true);
+ });
+
+ it('renders the GraphQL pagination component', () => {
+ expect(findGraphQlPagination().exists()).toBe(true);
+ expect(findRestPagination().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/releases/components/tag_field_exsting_spec.js b/spec/frontend/releases/components/tag_field_exsting_spec.js
index 0a04f68bd67..70a195556df 100644
--- a/spec/frontend/releases/components/tag_field_exsting_spec.js
+++ b/spec/frontend/releases/components/tag_field_exsting_spec.js
@@ -1,5 +1,6 @@
+import Vuex from 'vuex';
import { GlFormInput } from '@gitlab/ui';
-import { shallowMount, mount } from '@vue/test-utils';
+import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import TagFieldExisting from '~/releases/components/tag_field_existing.vue';
import createStore from '~/releases/stores';
import createDetailModule from '~/releases/stores/modules/detail';
@@ -7,6 +8,9 @@ import createDetailModule from '~/releases/stores/modules/detail';
const TEST_TAG_NAME = 'test-tag-name';
const TEST_DOCS_PATH = '/help/test/docs/path';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('releases/components/tag_field_existing', () => {
let store;
let wrapper;
@@ -14,6 +18,7 @@ describe('releases/components/tag_field_existing', () => {
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(TagFieldExisting, {
store,
+ localVue,
});
};
diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js
index b97385154bd..58cd69a2f6a 100644
--- a/spec/frontend/releases/mock_data.js
+++ b/spec/frontend/releases/mock_data.js
@@ -222,3 +222,131 @@ export const release2 = {
};
export const releases = [release, release2];
+
+export const graphqlReleasesResponse = {
+ data: {
+ project: {
+ releases: {
+ count: 39,
+ nodes: [
+ {
+ name: 'Release 1.0',
+ tagName: 'v5.10',
+ tagPath: '/root/release-test/-/tags/v5.10',
+ descriptionHtml:
+ '<p data-sourcepos="1:1-1:24" dir="auto">This is version <strong>1.0</strong>!</p>',
+ releasedAt: '2020-08-21T20:15:18Z',
+ upcomingRelease: false,
+ assets: {
+ count: 7,
+ sources: {
+ nodes: [
+ {
+ format: 'zip',
+ url:
+ 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip',
+ },
+ {
+ format: 'tar.gz',
+ url:
+ 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz',
+ },
+ {
+ format: 'tar.bz2',
+ url:
+ 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2',
+ },
+ {
+ format: 'tar',
+ url:
+ 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar',
+ },
+ ],
+ },
+ links: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Releases::Link/69',
+ name: 'An example link',
+ url: 'https://example.com/link',
+ directAssetUrl:
+ 'http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook',
+ linkType: 'OTHER',
+ external: true,
+ },
+ {
+ id: 'gid://gitlab/Releases::Link/68',
+ name: 'An example package link',
+ url: 'https://example.com/package',
+ directAssetUrl: 'https://example.com/package',
+ linkType: 'PACKAGE',
+ external: true,
+ },
+ {
+ id: 'gid://gitlab/Releases::Link/67',
+ name: 'An example image',
+ url: 'https://example.com/image',
+ directAssetUrl: 'https://example.com/image',
+ linkType: 'IMAGE',
+ external: true,
+ },
+ ],
+ },
+ },
+ evidences: {
+ nodes: [
+ {
+ filepath:
+ 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json',
+ collectedAt: '2020-08-21T20:15:19Z',
+ sha: '22bde8e8b93d870a29ddc339287a1fbb598f45d1396d',
+ },
+ ],
+ },
+ links: {
+ editUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit',
+ issuesUrl: null,
+ mergeRequestsUrl: null,
+ selfUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10',
+ },
+ commit: {
+ sha: '92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7',
+ webUrl:
+ 'http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7',
+ title: 'Testing a change.',
+ },
+ author: {
+ webUrl: 'http://0.0.0.0:3000/root',
+ avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png',
+ username: 'root',
+ },
+ milestones: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Milestone/60',
+ title: '12.4',
+ description: '',
+ webPath: '/root/release-test/-/milestones/2',
+ stats: {
+ totalIssuesCount: 0,
+ closedIssuesCount: 0,
+ },
+ },
+ {
+ id: 'gid://gitlab/Milestone/59',
+ title: '12.3',
+ description: 'Milestone 12.3',
+ webPath: '/root/release-test/-/milestones/1',
+ stats: {
+ totalIssuesCount: 2,
+ closedIssuesCount: 1,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ },
+};
diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js
index 4c3af157684..95e30659d6c 100644
--- a/spec/frontend/releases/stores/modules/list/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/list/actions_spec.js
@@ -1,3 +1,4 @@
+import { cloneDeep } from 'lodash';
import testAction from 'helpers/vuex_action_helper';
import {
requestReleases,
@@ -5,21 +6,43 @@ import {
receiveReleasesSuccess,
receiveReleasesError,
} from '~/releases/stores/modules/list/actions';
-import state from '~/releases/stores/modules/list/state';
+import createState from '~/releases/stores/modules/list/state';
import * as types from '~/releases/stores/modules/list/mutation_types';
import api from '~/api';
+import { gqClient, convertGraphQLResponse } from '~/releases/util';
import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { pageInfoHeadersWithoutPagination, releases as originalReleases } from '../../../mock_data';
+import {
+ pageInfoHeadersWithoutPagination,
+ releases as originalReleases,
+ graphqlReleasesResponse as originalGraphqlReleasesResponse,
+} from '../../../mock_data';
+import allReleasesQuery from '~/releases/queries/all_releases.query.graphql';
describe('Releases State actions', () => {
let mockedState;
let pageInfo;
let releases;
+ let graphqlReleasesResponse;
+
+ const projectPath = 'root/test-project';
+ const projectId = 19;
beforeEach(() => {
- mockedState = state();
+ mockedState = {
+ ...createState({
+ projectId,
+ projectPath,
+ }),
+ featureFlags: {
+ graphqlReleaseData: true,
+ graphqlReleasesPage: true,
+ graphqlMilestoneStats: true,
+ },
+ };
+
pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
releases = convertObjectPropsToCamelCase(originalReleases, { deep: true });
+ graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse);
});
describe('requestReleases', () => {
@@ -31,15 +54,17 @@ describe('Releases State actions', () => {
describe('fetchReleases', () => {
describe('success', () => {
it('dispatches requestReleases and receiveReleasesSuccess', done => {
- jest.spyOn(api, 'releases').mockImplementation((id, options) => {
- expect(id).toEqual(1);
- expect(options.page).toEqual('1');
- return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
+ jest.spyOn(gqClient, 'query').mockImplementation(({ query, variables }) => {
+ expect(query).toBe(allReleasesQuery);
+ expect(variables).toEqual({
+ fullPath: projectPath,
+ });
+ return Promise.resolve(graphqlReleasesResponse);
});
testAction(
fetchReleases,
- { projectId: 1 },
+ {},
mockedState,
[],
[
@@ -47,31 +72,7 @@ describe('Releases State actions', () => {
type: 'requestReleases',
},
{
- payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
- type: 'receiveReleasesSuccess',
- },
- ],
- done,
- );
- });
-
- it('dispatches requestReleases and receiveReleasesSuccess on page two', done => {
- jest.spyOn(api, 'releases').mockImplementation((_, options) => {
- expect(options.page).toEqual('2');
- return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
- });
-
- testAction(
- fetchReleases,
- { page: '2', projectId: 1 },
- mockedState,
- [],
- [
- {
- type: 'requestReleases',
- },
- {
- payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
+ payload: convertGraphQLResponse(graphqlReleasesResponse),
type: 'receiveReleasesSuccess',
},
],
@@ -82,11 +83,11 @@ describe('Releases State actions', () => {
describe('error', () => {
it('dispatches requestReleases and receiveReleasesError', done => {
- jest.spyOn(api, 'releases').mockReturnValue(Promise.reject());
+ jest.spyOn(gqClient, 'query').mockRejectedValue();
testAction(
fetchReleases,
- { projectId: null },
+ {},
mockedState,
[],
[
@@ -101,6 +102,85 @@ describe('Releases State actions', () => {
);
});
});
+
+ describe('when the graphqlReleaseData feature flag is disabled', () => {
+ beforeEach(() => {
+ mockedState.featureFlags.graphqlReleasesPage = false;
+ });
+
+ describe('success', () => {
+ it('dispatches requestReleases and receiveReleasesSuccess', done => {
+ jest.spyOn(api, 'releases').mockImplementation((id, options) => {
+ expect(id).toBe(projectId);
+ expect(options.page).toBe('1');
+ return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
+ });
+
+ testAction(
+ fetchReleases,
+ {},
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestReleases',
+ },
+ {
+ payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
+ type: 'receiveReleasesSuccess',
+ },
+ ],
+ done,
+ );
+ });
+
+ it('dispatches requestReleases and receiveReleasesSuccess on page two', done => {
+ jest.spyOn(api, 'releases').mockImplementation((_, options) => {
+ expect(options.page).toBe('2');
+ return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
+ });
+
+ testAction(
+ fetchReleases,
+ { page: '2' },
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestReleases',
+ },
+ {
+ payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
+ type: 'receiveReleasesSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestReleases and receiveReleasesError', done => {
+ jest.spyOn(api, 'releases').mockReturnValue(Promise.reject());
+
+ testAction(
+ fetchReleases,
+ {},
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestReleases',
+ },
+ {
+ type: 'receiveReleasesError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
});
describe('receiveReleasesSuccess', () => {
diff --git a/spec/frontend/releases/stores/modules/list/helpers.js b/spec/frontend/releases/stores/modules/list/helpers.js
index 435ca36047e..3ca255eaf8c 100644
--- a/spec/frontend/releases/stores/modules/list/helpers.js
+++ b/spec/frontend/releases/stores/modules/list/helpers.js
@@ -1,6 +1,5 @@
import state from '~/releases/stores/modules/list/state';
-// eslint-disable-next-line import/prefer-default-export
export const resetStore = store => {
store.replaceState(state());
};
diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js
index 3035b916ff6..27ad05846e7 100644
--- a/spec/frontend/releases/stores/modules/list/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js
@@ -1,4 +1,4 @@
-import state from '~/releases/stores/modules/list/state';
+import createState from '~/releases/stores/modules/list/state';
import mutations from '~/releases/stores/modules/list/mutations';
import * as types from '~/releases/stores/modules/list/mutation_types';
import { parseIntPagination } from '~/lib/utils/common_utils';
@@ -9,7 +9,7 @@ describe('Releases Store Mutations', () => {
let pageInfo;
beforeEach(() => {
- stateCopy = state();
+ stateCopy = createState({});
pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
});
diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js
index 90aa9c4c7d8..f40e5729188 100644
--- a/spec/frontend/releases/util_spec.js
+++ b/spec/frontend/releases/util_spec.js
@@ -1,4 +1,6 @@
-import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
+import { cloneDeep } from 'lodash';
+import { releaseToApiJson, apiJsonToRelease, convertGraphQLResponse } from '~/releases/util';
+import { graphqlReleasesResponse as originalGraphqlReleasesResponse } from './mock_data';
describe('releases/util.js', () => {
describe('releaseToApiJson', () => {
@@ -100,4 +102,55 @@ describe('releases/util.js', () => {
expect(apiJsonToRelease(json)).toEqual(expectedRelease);
});
});
+
+ describe('convertGraphQLResponse', () => {
+ let graphqlReleasesResponse;
+ let converted;
+
+ beforeEach(() => {
+ graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse);
+ converted = convertGraphQLResponse(graphqlReleasesResponse);
+ });
+
+ it('matches snapshot', () => {
+ expect(converted).toMatchSnapshot();
+ });
+
+ describe('assets', () => {
+ it("handles asset links that don't have a linkType", () => {
+ expect(converted.data[0].assets.links[0].linkType).not.toBeUndefined();
+
+ delete graphqlReleasesResponse.data.project.releases.nodes[0].assets.links.nodes[0]
+ .linkType;
+
+ converted = convertGraphQLResponse(graphqlReleasesResponse);
+
+ expect(converted.data[0].assets.links[0].linkType).toBeUndefined();
+ });
+ });
+
+ describe('_links', () => {
+ it("handles releases that don't have any links", () => {
+ expect(converted.data[0]._links.selfUrl).not.toBeUndefined();
+
+ delete graphqlReleasesResponse.data.project.releases.nodes[0].links;
+
+ converted = convertGraphQLResponse(graphqlReleasesResponse);
+
+ expect(converted.data[0]._links.selfUrl).toBeUndefined();
+ });
+ });
+
+ describe('commit', () => {
+ it("handles releases that don't have any commit info", () => {
+ expect(converted.data[0].commit).not.toBeUndefined();
+
+ delete graphqlReleasesResponse.data.project.releases.nodes[0].commit;
+
+ converted = convertGraphQLResponse(graphqlReleasesResponse);
+
+ expect(converted.data[0].commit).toBeUndefined();
+ });
+ });
+ });
});
diff --git a/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js
index a036588596a..ccceb78f2d1 100644
--- a/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js
+++ b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js
@@ -2,7 +2,7 @@ import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import GroupedAccessibilityReportsApp from '~/reports/accessibility_report/grouped_accessibility_reports_app.vue';
import AccessibilityIssueBody from '~/reports/accessibility_report/components/accessibility_issue_body.vue';
-import store from '~/reports/accessibility_report/store';
+import { getStoreConfig } from '~/reports/accessibility_report/store';
import { mockReport } from './mock_data';
const localVue = createLocalVue();
@@ -20,16 +20,17 @@ describe('Grouped accessibility reports app', () => {
propsData: {
endpoint: 'endpoint.json',
},
- methods: {
- fetchReport: () => {},
- },
});
};
const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]');
beforeEach(() => {
- mockStore = store();
+ mockStore = new Vuex.Store({
+ ...getStoreConfig(),
+ actions: { fetchReport: () => {}, setEndpoint: () => {} },
+ });
+
mountComponent();
});
diff --git a/spec/frontend/reports/accessibility_report/mock_data.js b/spec/frontend/reports/accessibility_report/mock_data.js
index 20ad01bd802..9dace1e7c54 100644
--- a/spec/frontend/reports/accessibility_report/mock_data.js
+++ b/spec/frontend/reports/accessibility_report/mock_data.js
@@ -1,4 +1,3 @@
-// eslint-disable-next-line import/prefer-default-export
export const mockReport = {
status: 'failed',
summary: {
diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js
index 1905ca0d5e1..77d7c6f8678 100644
--- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js
+++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js
@@ -2,7 +2,7 @@ import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import GroupedCodequalityReportsApp from '~/reports/codequality_report/grouped_codequality_reports_app.vue';
import CodequalityIssueBody from '~/reports/codequality_report/components/codequality_issue_body.vue';
-import store from '~/reports/codequality_report/store';
+import { getStoreConfig } from '~/reports/codequality_report/store';
import { mockParsedHeadIssues, mockParsedBaseIssues } from './mock_data';
const localVue = createLocalVue();
@@ -13,21 +13,22 @@ describe('Grouped code quality reports app', () => {
let wrapper;
let mockStore;
+ const PATHS = {
+ codequalityHelpPath: 'codequality_help.html',
+ basePath: 'base.json',
+ headPath: 'head.json',
+ baseBlobPath: 'base/blob/path/',
+ headBlobPath: 'head/blob/path/',
+ };
+
const mountComponent = (props = {}) => {
wrapper = mount(Component, {
store: mockStore,
localVue,
propsData: {
- basePath: 'base.json',
- headPath: 'head.json',
- baseBlobPath: 'base/blob/path/',
- headBlobPath: 'head/blob/path/',
- codequalityHelpPath: 'codequality_help.html',
+ ...PATHS,
...props,
},
- methods: {
- fetchReports: () => {},
- },
});
};
@@ -35,7 +36,19 @@ describe('Grouped code quality reports app', () => {
const findIssueBody = () => wrapper.find(CodequalityIssueBody);
beforeEach(() => {
- mockStore = store();
+ const { state, ...storeConfig } = getStoreConfig();
+ mockStore = new Vuex.Store({
+ ...storeConfig,
+ actions: {
+ setPaths: () => {},
+ fetchReports: () => {},
+ },
+ state: {
+ ...state,
+ ...PATHS,
+ },
+ });
+
mountComponent();
});
@@ -126,7 +139,11 @@ describe('Grouped code quality reports app', () => {
});
it('renders a help icon with more information', () => {
- expect(findWidget().html()).toContain('ic-question');
+ expect(
+ findWidget()
+ .find('[data-testid="question-icon"]')
+ .exists(),
+ ).toBe(true);
});
});
@@ -140,7 +157,11 @@ describe('Grouped code quality reports app', () => {
});
it('does not render a help icon', () => {
- expect(findWidget().html()).not.toContain('ic-question');
+ expect(
+ findWidget()
+ .find('[data-testid="question-icon"]')
+ .exists(),
+ ).toBe(false);
});
});
});
diff --git a/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap
index 70e1ff01323..b5a4cb42463 100644
--- a/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap
+++ b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap
@@ -4,7 +4,7 @@ exports[`IssueStatusIcon renders "failed" state correctly 1`] = `
<div
class="report-block-list-icon failed"
>
- <icon-stub
+ <gl-icon-stub
data-qa-selector="status_failed_icon"
name="status_failed_borderless"
size="24"
@@ -16,7 +16,7 @@ exports[`IssueStatusIcon renders "neutral" state correctly 1`] = `
<div
class="report-block-list-icon neutral"
>
- <icon-stub
+ <gl-icon-stub
data-qa-selector="status_neutral_icon"
name="dash"
size="24"
@@ -28,7 +28,7 @@ exports[`IssueStatusIcon renders "success" state correctly 1`] = `
<div
class="report-block-list-icon success"
>
- <icon-stub
+ <gl-icon-stub
data-qa-selector="status_success_icon"
name="status_success_borderless"
size="24"
diff --git a/spec/frontend/reports/components/grouped_issues_list_spec.js b/spec/frontend/reports/components/grouped_issues_list_spec.js
index 1f8f4a0e4c1..1172e514707 100644
--- a/spec/frontend/reports/components/grouped_issues_list_spec.js
+++ b/spec/frontend/reports/components/grouped_issues_list_spec.js
@@ -42,7 +42,7 @@ describe('Grouped Issues List', () => {
});
it.each('resolved', 'unresolved')('does not render report items for %s issues', () => {
- expect(wrapper.contains(ReportItem)).toBe(false);
+ expect(wrapper.find(ReportItem).exists()).toBe(false);
});
});
diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/components/grouped_test_reports_app_spec.js
index c26e2fbc19a..556904b7da5 100644
--- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js
+++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js
@@ -1,7 +1,7 @@
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import GroupedTestReportsApp from '~/reports/components/grouped_test_reports_app.vue';
-import store from '~/reports/store';
+import { getStoreConfig } from '~/reports/store';
import { failedReport } from '../mock_data/mock_data';
import successTestReports from '../mock_data/no_failures_report.json';
@@ -29,9 +29,6 @@ describe('Grouped test reports app', () => {
pipelinePath,
...props,
},
- methods: {
- fetchReports: () => {},
- },
});
};
@@ -49,7 +46,13 @@ describe('Grouped test reports app', () => {
wrapper.findAll('[data-testid="test-issue-body-description"]');
beforeEach(() => {
- mockStore = store();
+ mockStore = new Vuex.Store({
+ ...getStoreConfig(),
+ actions: {
+ fetchReports: () => {},
+ setEndpoint: () => {},
+ },
+ });
mountComponent();
});
diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js
index 10669330b61..1b8bbd5af6b 100644
--- a/spec/frontend/repository/components/table/index_spec.js
+++ b/spec/frontend/repository/components/table/index_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui';
import Table from '~/repository/components/table/index.vue';
import TableRow from '~/repository/components/table/row.vue';
@@ -34,12 +34,13 @@ const MOCK_BLOBS = [
},
];
-function factory({ path, isLoading = false, entries = {} }) {
+function factory({ path, isLoading = false, hasMore = true, entries = {} }) {
vm = shallowMount(Table, {
propsData: {
path,
isLoading,
entries,
+ hasMore,
},
mocks: {
$apollo,
@@ -88,4 +89,27 @@ describe('Repository table component', () => {
expect(rows.length).toEqual(3);
expect(rows.at(2).attributes().mode).toEqual('120000');
});
+
+ describe('Show more button', () => {
+ const showMoreButton = () => vm.find(GlButton);
+
+ it.each`
+ hasMore | expectButtonToExist
+ ${true} | ${true}
+ ${false} | ${false}
+ `('renders correctly', ({ hasMore, expectButtonToExist }) => {
+ factory({ path: '/', hasMore });
+ expect(showMoreButton().exists()).toBe(expectButtonToExist);
+ });
+
+ it('when is clicked, emits showMore event', async () => {
+ factory({ path: '/' });
+
+ showMoreButton().vm.$emit('click');
+
+ await vm.vm.$nextTick();
+
+ expect(vm.emitted('showMore')).toHaveLength(1);
+ });
+ });
});
diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js
index ea85cd34743..70dbfaea551 100644
--- a/spec/frontend/repository/components/tree_content_spec.js
+++ b/spec/frontend/repository/components/tree_content_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
-import { GlButton } from '@gitlab/ui';
import TreeContent, { INITIAL_FETCH_COUNT } from '~/repository/components/tree_content.vue';
import FilePreview from '~/repository/components/preview/index.vue';
+import FileTable from '~/repository/components/table/index.vue';
let vm;
let $apollo;
@@ -82,41 +82,36 @@ describe('Repository table component', () => {
});
});
- describe('Show more button', () => {
- const showMoreButton = () => vm.find(GlButton);
-
+ describe('FileTable showMore', () => {
describe('when is present', () => {
+ const fileTable = () => vm.find(FileTable);
+
beforeEach(async () => {
factory('/');
-
- vm.setData({ fetchCounter: 10, clickedShowMore: false });
-
- await vm.vm.$nextTick();
});
- it('is not rendered once it is clicked', async () => {
- showMoreButton().vm.$emit('click');
+ it('is changes hasShowMore to false when "showMore" event is emitted', async () => {
+ fileTable().vm.$emit('showMore');
+
await vm.vm.$nextTick();
- expect(showMoreButton().exists()).toBe(false);
+ expect(vm.vm.hasShowMore).toBe(false);
});
- it('is rendered', async () => {
- expect(showMoreButton().exists()).toBe(true);
- });
+ it('changes clickedShowMore when "showMore" event is emitted', async () => {
+ fileTable().vm.$emit('showMore');
- it('changes clickedShowMore when show more button is clicked', async () => {
- showMoreButton().vm.$emit('click');
+ await vm.vm.$nextTick();
expect(vm.vm.clickedShowMore).toBe(true);
});
- it('triggers fetchFiles when show more button is clicked', async () => {
+ it('triggers fetchFiles when "showMore" event is emitted', () => {
jest.spyOn(vm.vm, 'fetchFiles');
- showMoreButton().vm.$emit('click');
+ fileTable().vm.$emit('showMore');
- expect(vm.vm.fetchFiles).toBeCalled();
+ expect(vm.vm.fetchFiles).toHaveBeenCalled();
});
});
@@ -127,7 +122,7 @@ describe('Repository table component', () => {
await vm.vm.$nextTick();
- expect(showMoreButton().exists()).toBe(false);
+ expect(vm.vm.hasShowMore).toBe(false);
});
it('has limit of 1000 files on initial load', () => {
diff --git a/spec/frontend/repository/components/web_ide_link_spec.js b/spec/frontend/repository/components/web_ide_link_spec.js
deleted file mode 100644
index 877756db364..00000000000
--- a/spec/frontend/repository/components/web_ide_link_spec.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { mount } from '@vue/test-utils';
-import WebIdeLink from '~/repository/components/web_ide_link.vue';
-
-describe('Web IDE link component', () => {
- let wrapper;
-
- function createComponent(props) {
- wrapper = mount(WebIdeLink, {
- propsData: { ...props },
- mocks: {
- $route: {
- params: {},
- },
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders link to the Web IDE for a project if only projectPath is given', () => {
- createComponent({ projectPath: 'gitlab-org/gitlab', refSha: 'master' });
-
- expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/');
- expect(wrapper.text()).toBe('Web IDE');
- });
-
- it('renders link to the Web IDE for a project even if both projectPath and forkPath are given', () => {
- createComponent({
- projectPath: 'gitlab-org/gitlab',
- refSha: 'master',
- forkPath: 'my-namespace/gitlab',
- });
-
- expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/');
- expect(wrapper.text()).toBe('Web IDE');
- });
-
- it('renders link to the forked project if it exists and cannot write to the repo', () => {
- createComponent({
- projectPath: 'gitlab-org/gitlab',
- refSha: 'master',
- forkPath: 'my-namespace/gitlab',
- canPushCode: false,
- });
-
- expect(wrapper.attributes('href')).toBe('/-/ide/project/my-namespace/gitlab/edit/master/-/');
- expect(wrapper.text()).toBe('Edit fork in Web IDE');
- });
-});
diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js
index 5637d0be957..954424b5c8a 100644
--- a/spec/frontend/repository/log_tree_spec.js
+++ b/spec/frontend/repository/log_tree_spec.js
@@ -100,24 +100,27 @@ describe('fetchLogsTree', () => {
);
}));
- it('writes query to client', () =>
- fetchLogsTree(client, '', '0', resolver).then(() => {
- expect(client.writeQuery).toHaveBeenCalledWith({
- query: expect.anything(),
- data: {
- commits: [
- expect.objectContaining({
- __typename: 'LogTreeCommit',
- commitPath: 'https://test.com',
- committedDate: '2019-01-01',
- fileName: 'index.js',
- filePath: '/index.js',
- message: 'testing message',
- sha: '123',
- type: 'blob',
- }),
- ],
- },
- });
- }));
+ it('writes query to client', async () => {
+ await fetchLogsTree(client, '', '0', resolver);
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: expect.anything(),
+ data: {
+ projectPath: 'gitlab-org/gitlab-foss',
+ escapedRef: 'master',
+ commits: [
+ expect.objectContaining({
+ __typename: 'LogTreeCommit',
+ commitPath: 'https://test.com',
+ committedDate: '2019-01-01',
+ fileName: 'index.js',
+ filePath: '/index.js',
+ message: 'testing message',
+ sha: '123',
+ titleHtml: undefined,
+ type: 'blob',
+ }),
+ ],
+ },
+ });
+ });
});
diff --git a/spec/frontend/repository/router_spec.js b/spec/frontend/repository/router_spec.js
index f2f3dda41d9..3c7dda05ca3 100644
--- a/spec/frontend/repository/router_spec.js
+++ b/spec/frontend/repository/router_spec.js
@@ -4,12 +4,13 @@ import createRouter from '~/repository/router';
describe('Repository router spec', () => {
it.each`
- path | branch | component | componentName
- ${'/'} | ${'master'} | ${IndexPage} | ${'IndexPage'}
- ${'/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'}
- ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'}
- ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'}
- ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'}
+ path | branch | component | componentName
+ ${'/'} | ${'master'} | ${IndexPage} | ${'IndexPage'}
+ ${'/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'}
+ ${'/tree/feat(test)'} | ${'feat(test)'} | ${TreePage} | ${'TreePage'}
+ ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'}
+ ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'}
+ ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'}
`('sets component as $componentName for path "$path"', ({ path, component, branch }) => {
const router = createRouter('', branch);
diff --git a/spec/frontend/search/components/state_filter_spec.js b/spec/frontend/search/components/state_filter_spec.js
new file mode 100644
index 00000000000..26344f2b592
--- /dev/null
+++ b/spec/frontend/search/components/state_filter_spec.js
@@ -0,0 +1,104 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import StateFilter from '~/search/state_filter/components/state_filter.vue';
+import {
+ FILTER_STATES,
+ SCOPES,
+ FILTER_STATES_BY_SCOPE,
+ FILTER_TEXT,
+} from '~/search/state_filter/constants';
+import * as urlUtils from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ setUrlParams: jest.fn(),
+}));
+
+function createComponent(props = { scope: 'issues' }) {
+ return shallowMount(StateFilter, {
+ propsData: {
+ ...props,
+ },
+ });
+}
+
+describe('StateFilter', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findGlDropdown = () => wrapper.find(GlDropdown);
+ const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
+ const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text());
+ const firstDropDownItem = () => findGlDropdownItems().at(0);
+
+ describe('template', () => {
+ describe.each`
+ scope | showStateDropdown
+ ${'issues'} | ${true}
+ ${'merge_requests'} | ${true}
+ ${'projects'} | ${false}
+ ${'milestones'} | ${false}
+ ${'users'} | ${false}
+ ${'notes'} | ${false}
+ ${'wiki_blobs'} | ${false}
+ ${'blobs'} | ${false}
+ `(`state dropdown`, ({ scope, showStateDropdown }) => {
+ beforeEach(() => {
+ wrapper = createComponent({ scope });
+ });
+
+ it(`does${showStateDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
+ expect(findGlDropdown().exists()).toBe(showStateDropdown);
+ });
+ });
+
+ describe.each`
+ state | label
+ ${FILTER_STATES.ANY.value} | ${FILTER_TEXT}
+ ${FILTER_STATES.OPEN.value} | ${FILTER_STATES.OPEN.label}
+ ${FILTER_STATES.CLOSED.value} | ${FILTER_STATES.CLOSED.label}
+ ${FILTER_STATES.MERGED.value} | ${FILTER_STATES.MERGED.label}
+ `(`filter text`, ({ state, label }) => {
+ describe(`when state is ${state}`, () => {
+ beforeEach(() => {
+ wrapper = createComponent({ scope: 'issues', state });
+ });
+
+ it(`sets dropdown label to ${label}`, () => {
+ expect(findGlDropdown().attributes('text')).toBe(label);
+ });
+ });
+ });
+
+ describe('Filter options', () => {
+ it('renders a dropdown item for each filterOption', () => {
+ expect(findDropdownItemsText()).toStrictEqual(
+ FILTER_STATES_BY_SCOPE[SCOPES.ISSUES].map(v => {
+ return v.label;
+ }),
+ );
+ });
+
+ it('clicking a dropdown item calls setUrlParams', () => {
+ const state = FILTER_STATES[Object.keys(FILTER_STATES)[0]].value;
+ firstDropDownItem().vm.$emit('click');
+
+ expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ state });
+ });
+
+ it('clicking a dropdown item calls visitUrl', () => {
+ firstDropDownItem().vm.$emit('click');
+
+ expect(urlUtils.visitUrl).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js
index ee46dc015af..3240664f5aa 100644
--- a/spec/frontend/search_autocomplete_spec.js
+++ b/spec/frontend/search_autocomplete_spec.js
@@ -1,7 +1,6 @@
/* eslint-disable no-unused-expressions, consistent-return, no-param-reassign, default-case, no-return-assign */
import $ from 'jquery';
-import '~/gl_dropdown';
import AxiosMockAdapter from 'axios-mock-adapter';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import initSearchAutocomplete from '~/search_autocomplete';
@@ -215,7 +214,7 @@ describe('Search autocomplete dropdown', () => {
function triggerAutocomplete() {
return new Promise(resolve => {
- const dropdown = widget.searchInput.data('glDropdown');
+ const dropdown = widget.searchInput.data('deprecatedJQueryDropdown');
const filterCallback = dropdown.filter.options.callback;
dropdown.filter.options.callback = jest.fn(data => {
filterCallback(data);
diff --git a/spec/frontend/serverless/utils.js b/spec/frontend/serverless/utils.js
index ba451b7d573..4af3eda1ffb 100644
--- a/spec/frontend/serverless/utils.js
+++ b/spec/frontend/serverless/utils.js
@@ -1,4 +1,3 @@
-// eslint-disable-next-line import/prefer-default-export
export const adjustMetricQuery = data => {
const updatedMetric = data.metrics;
diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js
index 3d16074154c..538b3afa50f 100644
--- a/spec/frontend/shortcuts_spec.js
+++ b/spec/frontend/shortcuts_spec.js
@@ -1,6 +1,18 @@
import $ from 'jquery';
+import { flatten } from 'lodash';
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', () => {});
+
describe('Shortcuts', () => {
const fixtureName = 'snippets/show.html';
const createEvent = (type, target) =>
@@ -10,16 +22,16 @@ describe('Shortcuts', () => {
preloadFixtures(fixtureName);
- describe('toggleMarkdownPreview', () => {
- beforeEach(() => {
- loadFixtures(fixtureName);
+ beforeEach(() => {
+ loadFixtures(fixtureName);
- 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('.js-new-note-form .js-md-preview-button'), 'focus');
+ jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus');
- new Shortcuts(); // eslint-disable-line no-new
- });
+ new Shortcuts(); // eslint-disable-line no-new
+ });
+ describe('toggleMarkdownPreview', () => {
it('focuses preview button in form', () => {
Shortcuts.toggleMarkdownPreview(
createEvent('KeyboardEvent', document.querySelector('.js-new-note-form .js-note-text')),
@@ -43,4 +55,63 @@ describe('Shortcuts', () => {
expect(document.querySelector('.edit-note .js-md-preview-button').focus).toHaveBeenCalled();
});
});
+
+ describe('markdown shortcuts', () => {
+ let shortcuts;
+
+ beforeEach(() => {
+ // Get all shortcuts specified with md-shortcuts attributes in the fixture.
+ // `shortcuts` will look something like this:
+ // [
+ // [ 'mod+b' ],
+ // [ 'mod+i' ],
+ // [ 'mod+k' ]
+ // ]
+ shortcuts = $('.edit-note .js-md')
+ .map(function getShortcutsFromToolbarBtn() {
+ const mdShortcuts = $(this).data('md-shortcuts');
+
+ // jQuery.map() automatically unwraps arrays, so we
+ // have to double wrap the array to counteract this:
+ // https://stackoverflow.com/a/4875669/1063392
+ return mdShortcuts ? [mdShortcuts] : undefined;
+ })
+ .get();
+ });
+
+ describe('initMarkdownEditorShortcuts', () => {
+ beforeEach(() => {
+ Shortcuts.initMarkdownEditorShortcuts($('.edit-note textarea'));
+ });
+
+ it('attaches a Mousetrap handler for every markdown shortcut specified with md-shortcuts', () => {
+ const expectedCalls = shortcuts.map(s => [s, expect.any(Function)]);
+
+ expect(mockMousetrap.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);
+ });
+ });
+ });
+
+ describe('removeMarkdownEditorShortcuts', () => {
+ it('does nothing if initMarkdownEditorShortcuts was not previous called', () => {
+ Shortcuts.removeMarkdownEditorShortcuts($('.edit-note textarea'));
+
+ expect(mockMousetrap.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]);
+
+ expect(mockMousetrap.unbind.mock.calls).toEqual(expectedCalls);
+ });
+ });
+ });
});
diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
index 4c1ab4a499c..11ab1ca3aaa 100644
--- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
+++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
@@ -7,13 +7,9 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i
>
<div
class="sidebar-collapsed-icon"
- data-boundary="viewport"
- data-container="body"
- data-original-title="Not confidential"
- data-placement="left"
- title=""
+ title="Not confidential"
>
- <icon-stub
+ <gl-icon-stub
aria-hidden="true"
name="eye"
size="16"
@@ -38,7 +34,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i
class="no-value sidebar-item-value"
data-testid="not-confidential"
>
- <icon-stub
+ <gl-icon-stub
aria-hidden="true"
class="sidebar-item-icon inline"
name="eye"
@@ -59,13 +55,9 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i
>
<div
class="sidebar-collapsed-icon"
- data-boundary="viewport"
- data-container="body"
- data-original-title="Not confidential"
- data-placement="left"
- title=""
+ title="Not confidential"
>
- <icon-stub
+ <gl-icon-stub
aria-hidden="true"
name="eye"
size="16"
@@ -98,7 +90,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i
class="no-value sidebar-item-value"
data-testid="not-confidential"
>
- <icon-stub
+ <gl-icon-stub
aria-hidden="true"
class="sidebar-item-icon inline"
name="eye"
@@ -119,13 +111,9 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is
>
<div
class="sidebar-collapsed-icon"
- data-boundary="viewport"
- data-container="body"
- data-original-title="Confidential"
- data-placement="left"
- title=""
+ title="Confidential"
>
- <icon-stub
+ <gl-icon-stub
aria-hidden="true"
name="eye-slash"
size="16"
@@ -149,7 +137,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is
<div
class="value sidebar-item-value hide-collapsed"
>
- <icon-stub
+ <gl-icon-stub
aria-hidden="true"
class="sidebar-item-icon inline is-active"
name="eye-slash"
@@ -170,13 +158,9 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is
>
<div
class="sidebar-collapsed-icon"
- data-boundary="viewport"
- data-container="body"
- data-original-title="Confidential"
- data-placement="left"
- title=""
+ title="Confidential"
>
- <icon-stub
+ <gl-icon-stub
aria-hidden="true"
name="eye-slash"
size="16"
@@ -208,7 +192,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is
<div
class="value sidebar-item-value hide-collapsed"
>
- <icon-stub
+ <gl-icon-stub
aria-hidden="true"
class="sidebar-item-icon inline is-active"
name="eye-slash"
diff --git a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap
index 0a12eb327de..42012841f0b 100644
--- a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap
+++ b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap
@@ -13,7 +13,7 @@ exports[`SidebarTodo template renders component container element with proper da
title=""
type="button"
>
- <icon-stub
+ <gl-icon-stub
class="todo-undone"
name="todo-done"
size="16"
diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/assignees_spec.js
index 3418680f8ea..d1810ada97a 100644
--- a/spec/frontend/sidebar/assignees_spec.js
+++ b/spec/frontend/sidebar/assignees_spec.js
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
+import { GlIcon } from '@gitlab/ui';
import Assignee from '~/sidebar/components/assignees/assignees.vue';
import UsersMock from './mock_data';
import UsersMockHelper from '../helpers/user_mock_data_helper';
@@ -29,10 +30,12 @@ describe('Assignee component', () => {
it('displays no assignee icon when collapsed', () => {
createWrapper();
const collapsedChildren = findCollapsedChildren();
+ const userIcon = collapsedChildren.at(0).find(GlIcon);
expect(collapsedChildren.length).toBe(1);
expect(collapsedChildren.at(0).attributes('aria-label')).toBe('None');
- expect(collapsedChildren.at(0).classes()).toContain('fa', 'fa-user');
+ expect(userIcon.exists()).toBe(true);
+ expect(userIcon.props('name')).toBe('user');
});
it('displays only "None" when no users are assigned and the issue is read-only', () => {
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 a1e19c1dd8e..907d6144415 100644
--- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js
+++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import UsersMockHelper from 'helpers/user_mock_data_helper';
import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
import CollapsedAssignee from '~/sidebar/components/assignees/collapsed_assignee.vue';
@@ -20,7 +21,7 @@ describe('CollapsedAssigneeList component', () => {
});
}
- const findNoUsersIcon = () => wrapper.find('i[aria-label=None]');
+ const findNoUsersIcon = () => wrapper.find(GlIcon);
const findAvatarCounter = () => wrapper.find('.avatar-counter');
const findAssignees = () => wrapper.findAll(CollapsedAssignee);
const getTooltipTitle = () => wrapper.attributes('title');
@@ -38,6 +39,7 @@ describe('CollapsedAssigneeList component', () => {
it('has no users', () => {
expect(findNoUsersIcon().exists()).toBe(true);
+ expect(findNoUsersIcon().props('name')).toBe('user');
});
});
diff --git a/spec/frontend/sidebar/components/severity/severity_spec.js b/spec/frontend/sidebar/components/severity/severity_spec.js
new file mode 100644
index 00000000000..b6690f11d6b
--- /dev/null
+++ b/spec/frontend/sidebar/components/severity/severity_spec.js
@@ -0,0 +1,57 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import SeverityToken from '~/sidebar/components/severity/severity.vue';
+import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
+
+describe('SeverityToken', () => {
+ let wrapper;
+
+ function createComponent(props) {
+ wrapper = shallowMount(SeverityToken, {
+ propsData: {
+ ...props,
+ },
+ });
+ }
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findIcon = () => wrapper.find(GlIcon);
+
+ it('renders severity token for each severity type', () => {
+ Object.values(INCIDENT_SEVERITY).forEach(severity => {
+ createComponent({ severity });
+ expect(findIcon().classes()).toContain(`icon-${severity.icon}`);
+ expect(findIcon().attributes('name')).toBe(`severity-${severity.icon}`);
+ expect(wrapper.text()).toBe(severity.label);
+ });
+ });
+
+ it('renders only icon when `iconOnly` prop is set to `true`', () => {
+ const severity = INCIDENT_SEVERITY.CRITICAL;
+ createComponent({ severity, iconOnly: true });
+ expect(findIcon().classes()).toContain(`icon-${severity.icon}`);
+ expect(findIcon().attributes('name')).toBe(`severity-${severity.icon}`);
+ expect(wrapper.text()).toBe('');
+ });
+
+ describe('icon size', () => {
+ it('renders the icon in default size when other is not specified', () => {
+ const severity = INCIDENT_SEVERITY.HIGH;
+ createComponent({ severity });
+ expect(findIcon().attributes('size')).toBe('12');
+ });
+
+ it('renders the icon in provided size', () => {
+ const severity = INCIDENT_SEVERITY.HIGH;
+ const iconSize = 14;
+ createComponent({ severity, iconSize });
+ expect(findIcon().attributes('size')).toBe(`${iconSize}`);
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js
new file mode 100644
index 00000000000..638d3706d12
--- /dev/null
+++ b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js
@@ -0,0 +1,166 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue';
+import SeverityToken from '~/sidebar/components/severity/severity.vue';
+import updateIssuableSeverity from '~/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql';
+import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/components/severity/constants';
+
+jest.mock('~/flash');
+
+describe('SidebarSeverity', () => {
+ let wrapper;
+ let mutate;
+ const projectPath = 'gitlab-org/gitlab-test';
+ const iid = '1';
+ const severity = 'CRITICAL';
+
+ function createComponent(props = {}) {
+ const propsData = {
+ projectPath,
+ iid,
+ issuableType: ISSUABLE_TYPES.INCIDENT,
+ initialSeverity: severity,
+ ...props,
+ };
+ mutate = jest.fn();
+ wrapper = shallowMount(SidebarSeverity, {
+ propsData,
+ mocks: {
+ $apollo: {
+ mutate,
+ },
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findSeverityToken = () => wrapper.findAll(SeverityToken);
+ const findEditBtn = () => wrapper.find('[data-testid="editButton"]');
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findCriticalSeverityDropdownItem = () => wrapper.find(GlDropdownItem);
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+ const findTooltip = () => wrapper.find(GlTooltip);
+ const findCollapsedSeverity = () => wrapper.find({ ref: 'severity' });
+
+ it('renders severity widget', () => {
+ expect(findEditBtn().exists()).toBe(true);
+ expect(findSeverityToken().exists()).toBe(true);
+ expect(findDropdown().exists()).toBe(true);
+ });
+
+ 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 ', () => {
+ const errorMsg = 'Something went wrong';
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValueOnce(errorMsg);
+ findCriticalSeverityDropdownItem().vm.$emit('click');
+
+ setImmediate(() => {
+ expect(createFlash).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 wrapper.vm.$nextTick();
+ expect(findLoadingIcon().exists()).toBe(true);
+
+ resolvePromise();
+ await waitForPromises();
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('Switch between collapsed/expanded view of the sidebar', () => {
+ const HIDDDEN_CLASS = 'gl-display-none';
+ const SHOWN_CLASS = 'show';
+
+ 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()
+ .at(0)
+ .attributes('icononly'),
+ ).toBe('true');
+ expect(
+ findSeverityToken()
+ .at(0)
+ .attributes('iconsize'),
+ ).toBe('14');
+ expect(
+ findTooltip()
+ .text()
+ .replace(/\s+/g, ' '),
+ ).toContain(`Severity: ${INCIDENT_SEVERITY[severity].label}`);
+ });
+
+ it('should expand the dropdown on collapsed icon click', async () => {
+ wrapper.vm.isDropdownShowing = false;
+ await wrapper.vm.$nextTick();
+ expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true);
+
+ findCollapsedSeverity().trigger('click');
+ await wrapper.vm.$nextTick();
+ expect(findDropdown().classes(SHOWN_CLASS)).toBe(true);
+ });
+ });
+
+ describe('expanded', () => {
+ it('toggles dropdown with edit button', async () => {
+ wrapper.vm.isDropdownShowing = false;
+ await wrapper.vm.$nextTick();
+ expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true);
+
+ findEditBtn().vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ expect(findDropdown().classes(SHOWN_CLASS)).toBe(true);
+
+ findEditBtn().vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/issuable_assignees_spec.js
new file mode 100644
index 00000000000..aa930bd4198
--- /dev/null
+++ b/spec/frontend/sidebar/issuable_assignees_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
+import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue';
+
+describe('IssuableAssignees', () => {
+ let wrapper;
+
+ const createComponent = (props = { users: [] }) => {
+ wrapper = shallowMount(IssuableAssignees, {
+ provide: {
+ rootPath: '',
+ },
+ propsData: { ...props },
+ });
+ };
+ const findLabel = () => wrapper.find('[data-testid="assigneeLabel"');
+ const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList);
+ const findEmptyAssignee = () => wrapper.find('[data-testid="none"]');
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when no assignees are present', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders "None"', () => {
+ expect(findEmptyAssignee().text()).toBe('None');
+ });
+
+ it('renders "0 assignees"', () => {
+ expect(findLabel().text()).toBe('0 Assignees');
+ });
+ });
+
+ describe('when assignees are present', () => {
+ it('renders UncollapsedAssigneesList', () => {
+ createComponent({ users: [{ id: 1 }] });
+
+ expect(findUncollapsedAssigneeList().exists()).toBe(true);
+ });
+
+ it.each`
+ assignees | expected
+ ${[{ id: 1 }]} | ${'Assignee'}
+ ${[{ id: 1 }, { id: 2 }]} | ${'2 Assignees'}
+ `(
+ 'when assignees have a length of $assignees.length, it renders $expected',
+ ({ assignees, expected }) => {
+ createComponent({ users: assignees });
+
+ expect(findLabel().text()).toBe(expected);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js
index ebe94582588..93c9b3b84c3 100644
--- a/spec/frontend/sidebar/participants_spec.js
+++ b/spec/frontend/sidebar/participants_spec.js
@@ -37,7 +37,7 @@ describe('Participants', () => {
loading: true,
});
- expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('does not show loading spinner not loading', () => {
@@ -45,7 +45,7 @@ describe('Participants', () => {
loading: false,
});
- expect(wrapper.contains(GlLoadingIcon)).toBe(false);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('shows participant count when given', () => {
@@ -74,7 +74,7 @@ describe('Participants', () => {
loading: true,
});
- expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('when only showing visible participants, shows an avatar only for each participant under the limit', () => {
@@ -196,11 +196,11 @@ describe('Participants', () => {
});
it('does not show sidebar collapsed icon', () => {
- expect(wrapper.contains('.sidebar-collapsed-icon')).toBe(false);
+ expect(wrapper.find('.sidebar-collapsed-icon').exists()).toBe(false);
});
it('does not show participants label title', () => {
- expect(wrapper.contains('.title')).toBe(false);
+ expect(wrapper.find('.title').exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js
new file mode 100644
index 00000000000..29333a344e1
--- /dev/null
+++ b/spec/frontend/sidebar/sidebar_labels_spec.js
@@ -0,0 +1,124 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import Vuex from 'vuex';
+import {
+ mockLabels,
+ mockRegularLabel,
+} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
+import axios from '~/lib/utils/axios_utils';
+import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
+import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('sidebar labels', () => {
+ let axiosMock;
+ let wrapper;
+
+ const store = new Vuex.Store(labelsSelectModule());
+
+ const defaultProps = {
+ allowLabelCreate: true,
+ allowLabelEdit: true,
+ allowScopedLabels: true,
+ canEdit: true,
+ iid: '1',
+ initiallySelectedLabels: mockLabels,
+ issuableType: 'issue',
+ labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true',
+ labelsManagePath: '/gitlab-org/gitlab-test/-/labels',
+ labelsUpdatePath: '/gitlab-org/gitlab-test/-/issues/1.json',
+ projectIssuesPath: '/gitlab-org/gitlab-test/-/issues',
+ projectPath: 'gitlab-org/gitlab-test',
+ };
+
+ const findLabelsSelect = () => wrapper.find(LabelsSelect);
+
+ const mountComponent = () => {
+ wrapper = shallowMount(SidebarLabels, {
+ localVue,
+ provide: {
+ ...defaultProps,
+ },
+ store,
+ });
+ };
+
+ beforeEach(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ axiosMock.restore();
+ });
+
+ describe('LabelsSelect props', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('are as expected', () => {
+ expect(findLabelsSelect().props()).toMatchObject({
+ allowLabelCreate: defaultProps.allowLabelCreate,
+ allowLabelEdit: defaultProps.allowLabelEdit,
+ allowMultiselect: true,
+ allowScopedLabels: defaultProps.allowScopedLabels,
+ footerCreateLabelTitle: 'Create project label',
+ footerManageLabelTitle: 'Manage project labels',
+ labelsCreateTitle: 'Create project label',
+ labelsFetchPath: defaultProps.labelsFetchPath,
+ labelsFilterBasePath: defaultProps.projectIssuesPath,
+ labelsManagePath: defaultProps.labelsManagePath,
+ labelsSelectInProgress: false,
+ selectedLabels: defaultProps.initiallySelectedLabels,
+ variant: DropdownVariant.Sidebar,
+ });
+ });
+ });
+
+ describe('when labels are changed', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('makes an API call to update labels', async () => {
+ const labels = [
+ {
+ ...mockRegularLabel,
+ set: false,
+ },
+ {
+ id: 40,
+ title: 'Security',
+ color: '#ddd',
+ text_color: '#fff',
+ set: true,
+ },
+ {
+ id: 55,
+ title: 'Tooling',
+ color: '#ddd',
+ text_color: '#fff',
+ set: false,
+ },
+ ];
+
+ findLabelsSelect().vm.$emit('updateSelectedLabels', labels);
+
+ await axios.waitForAll();
+
+ const expected = {
+ [defaultProps.issuableType]: {
+ label_ids: [27, 28, 40],
+ },
+ };
+
+ expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected));
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/sidebar_move_issue_spec.js
index db0d3e06272..ad919f69546 100644
--- a/spec/frontend/sidebar/sidebar_move_issue_spec.js
+++ b/spec/frontend/sidebar/sidebar_move_issue_spec.js
@@ -68,12 +68,10 @@ describe('SidebarMoveIssue', () => {
});
describe('initDropdown', () => {
- it('should initialize the gl_dropdown', () => {
- jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {});
-
+ it('should initialize the deprecatedJQueryDropdown', () => {
test.sidebarMoveIssue.initDropdown();
- expect($.fn.glDropdown).toHaveBeenCalled();
+ expect(test.sidebarMoveIssue.$dropdownToggle.data('deprecatedJQueryDropdown')).toBeTruthy();
});
it('escapes html from project name', done => {
diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js
index cce35666985..dddb9c2bba9 100644
--- a/spec/frontend/sidebar/subscriptions_spec.js
+++ b/spec/frontend/sidebar/subscriptions_spec.js
@@ -100,7 +100,7 @@ describe('Subscriptions', () => {
});
it('does not render the toggle button', () => {
- expect(wrapper.contains('.js-issuable-subscribe-button')).toBe(false);
+ expect(wrapper.find('.js-issuable-subscribe-button').exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/todo_spec.js
index e56a78989eb..b0e94f16dd7 100644
--- a/spec/frontend/sidebar/todo_spec.js
+++ b/spec/frontend/sidebar/todo_spec.js
@@ -1,8 +1,7 @@
import { shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import SidebarTodos from '~/sidebar/components/todo_toggle/todo.vue';
-import Icon from '~/vue_shared/components/icon.vue';
const defaultProps = {
issuableId: 1,
@@ -45,11 +44,11 @@ describe('SidebarTodo', () => {
expect(
wrapper
- .find(Icon)
+ .find(GlIcon)
.classes()
.join(' '),
).toStrictEqual(iconClass);
- expect(wrapper.find(Icon).props('name')).toStrictEqual(icon);
+ expect(wrapper.find(GlIcon).props('name')).toStrictEqual(icon);
expect(wrapper.find('button').text()).toBe(label);
},
);
@@ -82,7 +81,7 @@ describe('SidebarTodo', () => {
it('renders button icon when `collapsed` prop is `true`', () => {
createComponent({ collapsed: true });
- expect(wrapper.find(Icon).props('name')).toBe('todo-done');
+ expect(wrapper.find(GlIcon).props('name')).toBe('todo-done');
});
it('renders loading icon when `isActionActive` prop is true', () => {
@@ -94,7 +93,7 @@ describe('SidebarTodo', () => {
it('hides button icon when `isActionActive` prop is true', () => {
createComponent({ collapsed: true, isActionActive: true });
- expect(wrapper.find(Icon).isVisible()).toBe(false);
+ expect(wrapper.find(GlIcon).isVisible()).toBe(false);
});
});
});
diff --git a/spec/frontend/snippet/snippet_bundle_spec.js b/spec/frontend/snippet/snippet_bundle_spec.js
index ad69a91fe89..208d2fea804 100644
--- a/spec/frontend/snippet/snippet_bundle_spec.js
+++ b/spec/frontend/snippet/snippet_bundle_spec.js
@@ -15,11 +15,13 @@ describe('Snippet editor', () => {
const updatedMockContent = 'New Foo Bar';
const mockEditor = {
- createInstance: jest.fn(),
updateModelLanguage: jest.fn(),
getValue: jest.fn().mockReturnValueOnce(updatedMockContent),
};
- Editor.mockImplementation(() => mockEditor);
+ const createInstance = jest.fn().mockImplementation(() => ({ ...mockEditor }));
+ Editor.mockImplementation(() => ({
+ createInstance,
+ }));
function setUpFixture(name, content) {
setHTMLFixture(`
@@ -56,7 +58,7 @@ describe('Snippet editor', () => {
});
it('correctly initializes Editor', () => {
- expect(mockEditor.createInstance).toHaveBeenCalledWith({
+ expect(createInstance).toHaveBeenCalledWith({
el: editorEl,
blobPath: mockName,
blobContent: mockContent,
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 6020d595e3f..3b101e9e815 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
@@ -41,7 +41,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
>
<textarea
aria-label="Description"
- class="note-textarea js-gfm-input js-autosize markdown-area"
+ class="note-textarea js-gfm-input js-autosize markdown-area js-gfm-input-initialized"
data-qa-selector="snippet_description_field"
data-supports-quick-actions="false"
dir="auto"
@@ -63,8 +63,8 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
class="zen-control zen-control-leave js-zen-leave gl-text-gray-500"
href="#"
>
- <icon-stub
- name="screen-normal"
+ <gl-icon-stub
+ name="minimize"
size="16"
/>
</a>
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 be75a5bfbdc..8446f0f50c4 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
@@ -20,6 +20,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] =
</label>
<gl-form-group-stub
+ class="gl-mb-0"
id="visibility-level-setting"
>
<gl-form-radio-group-stub
@@ -90,5 +91,12 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] =
</gl-form-radio-stub>
</gl-form-radio-group-stub>
</gl-form-group-stub>
+
+ <div
+ class="text-muted"
+ data-testid="restricted-levels-info"
+ >
+ <!---->
+ </div>
</div>
`;
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index ebab6aa84f6..b6abb9f389a 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -47,6 +47,8 @@ const createTestSnippet = () => ({
describe('Snippet Edit app', () => {
let wrapper;
+ const relativeUrlRoot = '/foo/';
+ const originalRelativeUrlRoot = gon.relative_url_root;
const mutationTypes = {
RESOLVE: jest.fn().mockResolvedValue({
@@ -100,16 +102,25 @@ describe('Snippet Edit app', () => {
markdownDocsPath: 'http://docs.foo.bar',
...props,
},
+ data() {
+ return {
+ snippet: {
+ visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
+ },
+ };
+ },
});
}
beforeEach(() => {
+ gon.relative_url_root = relativeUrlRoot;
jest.spyOn(urlUtils, 'redirectTo').mockImplementation();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
+ gon.relative_url_root = originalRelativeUrlRoot;
});
const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit);
@@ -164,10 +175,10 @@ describe('Snippet Edit app', () => {
props => {
createComponent(props);
- expect(wrapper.contains(TitleField)).toBe(true);
- expect(wrapper.contains(SnippetDescriptionEdit)).toBe(true);
- expect(wrapper.contains(SnippetVisibilityEdit)).toBe(true);
- expect(wrapper.contains(FormFooterActions)).toBe(true);
+ expect(wrapper.find(TitleField).exists()).toBe(true);
+ expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
+ expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
+ expect(wrapper.find(FormFooterActions).exists()).toBe(true);
expect(findBlobActions().exists()).toBe(true);
},
);
@@ -196,8 +207,8 @@ describe('Snippet Edit app', () => {
it.each`
projectPath | snippetArg | expectation
- ${''} | ${[]} | ${'/-/snippets'}
- ${'project/path'} | ${[]} | ${'/project/path/-/snippets'}
+ ${''} | ${[]} | ${urlUtils.joinPaths('/', relativeUrlRoot, '-', 'snippets')}
+ ${'project/path'} | ${[]} | ${urlUtils.joinPaths('/', relativeUrlRoot, 'project/path/-', 'snippets')}
${''} | ${[createTestSnippet()]} | ${TEST_WEB_URL}
${'project/path'} | ${[createTestSnippet()]} | ${TEST_WEB_URL}
`(
diff --git a/spec/frontend/snippets/components/embed_dropdown_spec.js b/spec/frontend/snippets/components/embed_dropdown_spec.js
new file mode 100644
index 00000000000..8eb44965692
--- /dev/null
+++ b/spec/frontend/snippets/components/embed_dropdown_spec.js
@@ -0,0 +1,70 @@
+import { escape as esc } from 'lodash';
+import { mount } from '@vue/test-utils';
+import { GlFormInputGroup } from '@gitlab/ui';
+import { TEST_HOST } from 'helpers/test_constants';
+import EmbedDropdown from '~/snippets/components/embed_dropdown.vue';
+
+const TEST_URL = `${TEST_HOST}/test/no">'xss`;
+
+describe('snippets/components/embed_dropdown', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = mount(EmbedDropdown, {
+ propsData: {
+ url: TEST_URL,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findSectionsData = () => {
+ const sections = [];
+ let current = {};
+
+ wrapper.findAll('[data-testid="header"],[data-testid="input"]').wrappers.forEach(x => {
+ const type = x.attributes('data-testid');
+
+ if (type === 'header') {
+ current = {
+ header: x.text(),
+ };
+
+ sections.push(current);
+ } else {
+ const value = x.find(GlFormInputGroup).props('value');
+ const copyValue = x.find('button[title="Copy"]').attributes('data-clipboard-text');
+
+ Object.assign(current, {
+ value,
+ copyValue,
+ });
+ }
+ });
+
+ return sections;
+ };
+
+ it('renders dropdown items', () => {
+ createComponent();
+
+ const embedValue = `<script src="${esc(TEST_URL)}.js"></script>`;
+
+ expect(findSectionsData()).toEqual([
+ {
+ header: 'Embed',
+ value: embedValue,
+ copyValue: embedValue,
+ },
+ {
+ header: 'Share',
+ value: TEST_URL,
+ copyValue: TEST_URL,
+ },
+ ]);
+ });
+});
diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js
index 8cccbb83d54..b5ab7def753 100644
--- a/spec/frontend/snippets/components/show_spec.js
+++ b/spec/frontend/snippets/components/show_spec.js
@@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { Blob, BinaryBlob } from 'jest/blob/components/mock_data';
import { shallowMount } from '@vue/test-utils';
import SnippetApp from '~/snippets/components/show.vue';
-import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
+import EmbedDropdown from '~/snippets/components/embed_dropdown.vue';
import SnippetHeader from '~/snippets/components/snippet_header.vue';
import SnippetTitle from '~/snippets/components/snippet_title.vue';
import SnippetBlob from '~/snippets/components/snippet_blob_view.vue';
@@ -57,7 +57,7 @@ describe('Snippet view app', () => {
expect(wrapper.find(SnippetTitle).exists()).toBe(true);
});
- it('renders embeddable component if visibility allows', () => {
+ it('renders embed dropdown component if visibility allows', () => {
createComponent({
data: {
snippet: {
@@ -66,7 +66,7 @@ describe('Snippet view app', () => {
},
},
});
- expect(wrapper.contains(BlobEmbeddable)).toBe(true);
+ expect(wrapper.find(EmbedDropdown).exists()).toBe(true);
});
it('renders correct snippet-blob components', () => {
@@ -88,7 +88,7 @@ describe('Snippet view app', () => {
${SNIPPET_VISIBILITY_PRIVATE} | ${'not render'} | ${false}
${'foo'} | ${'not render'} | ${false}
${SNIPPET_VISIBILITY_PUBLIC} | ${'render'} | ${true}
- `('does $condition blob-embeddable by default', ({ visibilityLevel, isRendered }) => {
+ `('does $condition embed-dropdown by default', ({ visibilityLevel, isRendered }) => {
createComponent({
data: {
snippet: {
@@ -97,7 +97,7 @@ describe('Snippet view app', () => {
},
},
});
- expect(wrapper.contains(BlobEmbeddable)).toBe(isRendered);
+ expect(wrapper.find(EmbedDropdown).exists()).toBe(isRendered);
});
});
@@ -119,7 +119,7 @@ describe('Snippet view app', () => {
},
},
});
- expect(wrapper.contains(CloneDropdownButton)).toBe(isRendered);
+ expect(wrapper.find(CloneDropdownButton).exists()).toBe(isRendered);
},
);
});
diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js
index 188f9ae5cf1..fc4da46d722 100644
--- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js
@@ -17,6 +17,7 @@ const TEST_PATH = 'foo/bar/test.md';
const TEST_RAW_PATH = '/gitlab/raw/path/to/blob/7';
const TEST_FULL_PATH = joinPaths(TEST_HOST, TEST_RAW_PATH);
const TEST_CONTENT = 'Lorem ipsum dolar sit amet,\nconsectetur adipiscing elit.';
+const TEST_JSON_CONTENT = '{"abc":"lorem ipsum"}';
const TEST_BLOB = {
id: TEST_ID,
@@ -66,7 +67,7 @@ describe('Snippet Blob Edit component', () => {
});
describe('with not loaded blob', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
});
@@ -100,6 +101,20 @@ describe('Snippet Blob Edit component', () => {
});
});
+ describe('with unloaded blob and JSON content', () => {
+ beforeEach(() => {
+ axiosMock.onGet(TEST_FULL_PATH).reply(200, TEST_JSON_CONTENT);
+ createComponent();
+ });
+
+ // This checks against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/241199
+ it('emits raw content', async () => {
+ await waitForPromises();
+
+ expect(getLastUpdatedArgs()).toEqual({ content: TEST_JSON_CONTENT });
+ });
+ });
+
describe('with error', () => {
beforeEach(() => {
axiosMock.reset();
diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js
index da8cb2e6a8d..5836de1fdbe 100644
--- a/spec/frontend/snippets/components/snippet_header_spec.js
+++ b/spec/frontend/snippets/components/snippet_header_spec.js
@@ -5,6 +5,7 @@ import { Blob, BinaryBlob } from 'jest/blob/components/mock_data';
import waitForPromises from 'helpers/wait_for_promises';
import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql';
import SnippetHeader from '~/snippets/components/snippet_header.vue';
+import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
describe('Snippet header component', () => {
let wrapper;
@@ -14,6 +15,7 @@ describe('Snippet header component', () => {
let errorMsg;
let err;
+ const originalRelativeUrlRoot = gon.relative_url_root;
function createComponent({
loading = false,
@@ -50,6 +52,7 @@ describe('Snippet header component', () => {
}
beforeEach(() => {
+ gon.relative_url_root = '/foo/';
snippet = {
id: 'gid://gitlab/PersonalSnippet/50',
title: 'The property of Thor',
@@ -65,7 +68,7 @@ describe('Snippet header component', () => {
name: 'Thor Odinson',
},
blobs: [Blob],
- createdAt: new Date(Date.now() - 32 * 24 * 3600 * 1000).toISOString(),
+ createdAt: new Date(differenceInMilliseconds(32 * 24 * 3600 * 1000)).toISOString(),
};
mutationVariables = {
@@ -86,6 +89,7 @@ describe('Snippet header component', () => {
afterEach(() => {
wrapper.destroy();
+ gon.relative_url_root = originalRelativeUrlRoot;
});
it('renders itself', () => {
@@ -213,7 +217,7 @@ describe('Snippet header component', () => {
it('redirects to dashboard/snippets for personal snippet', () => {
return createDeleteSnippet().then(() => {
expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
- expect(window.location.pathname).toBe('dashboard/snippets');
+ expect(window.location.pathname).toBe(`${gon.relative_url_root}dashboard/snippets`);
});
});
diff --git a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js
index a8df13787a5..3919e4d7993 100644
--- a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js
@@ -1,31 +1,55 @@
import { GlFormRadio, GlIcon, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue';
+import { defaultSnippetVisibilityLevels } from '~/snippets/utils/blob';
import {
SNIPPET_VISIBILITY,
SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_VISIBILITY_INTERNAL,
SNIPPET_VISIBILITY_PUBLIC,
+ SNIPPET_LEVELS_RESTRICTED,
+ SNIPPET_LEVELS_DISABLED,
} from '~/snippets/constants';
describe('Snippet Visibility Edit component', () => {
let wrapper;
const defaultHelpLink = '/foo/bar';
const defaultVisibilityLevel = 'private';
-
- function createComponent(propsData = {}, deep = false) {
+ const defaultVisibility = defaultSnippetVisibilityLevels([0, 10, 20]);
+
+ function createComponent({
+ propsData = {},
+ visibilityLevels = defaultVisibility,
+ multipleLevelsRestricted = false,
+ deep = false,
+ } = {}) {
const method = deep ? mount : shallowMount;
+ const $apollo = {
+ queries: {
+ defaultVisibility: {
+ loading: false,
+ },
+ },
+ };
+
wrapper = method.call(this, SnippetVisibilityEdit, {
+ mock: { $apollo },
propsData: {
helpLink: defaultHelpLink,
isProjectSnippet: false,
value: defaultVisibilityLevel,
...propsData,
},
+ data() {
+ return {
+ visibilityLevels,
+ multipleLevelsRestricted,
+ };
+ },
});
}
- const findLabel = () => wrapper.find('label');
+ const findLink = () => wrapper.find('label').find(GlLink);
const findRadios = () => wrapper.find(GlFormRadioGroup).findAll(GlFormRadio);
const findRadiosData = () =>
findRadios().wrappers.map(x => {
@@ -47,56 +71,84 @@ describe('Snippet Visibility Edit component', () => {
expect(wrapper.element).toMatchSnapshot();
});
- it('renders visibility options', () => {
- createComponent({}, true);
+ it('renders label help link', () => {
+ createComponent();
+
+ expect(findLink().attributes('href')).toBe(defaultHelpLink);
+ });
+
+ it('when helpLink is not defined, does not render label help link', () => {
+ createComponent({ propsData: { helpLink: null } });
- expect(findRadiosData()).toEqual([
- {
+ expect(findLink().exists()).toBe(false);
+ });
+
+ describe('Visibility options', () => {
+ const findRestrictedInfo = () => wrapper.find('[data-testid="restricted-levels-info"]');
+ const RESULTING_OPTIONS = {
+ 0: {
value: SNIPPET_VISIBILITY_PRIVATE,
icon: SNIPPET_VISIBILITY.private.icon,
text: SNIPPET_VISIBILITY.private.label,
description: SNIPPET_VISIBILITY.private.description,
},
- {
+ 10: {
value: SNIPPET_VISIBILITY_INTERNAL,
icon: SNIPPET_VISIBILITY.internal.icon,
text: SNIPPET_VISIBILITY.internal.label,
description: SNIPPET_VISIBILITY.internal.description,
},
- {
+ 20: {
value: SNIPPET_VISIBILITY_PUBLIC,
icon: SNIPPET_VISIBILITY.public.icon,
text: SNIPPET_VISIBILITY.public.label,
description: SNIPPET_VISIBILITY.public.description,
},
- ]);
- });
-
- it('when project snippet, renders special private description', () => {
- createComponent({ isProjectSnippet: true }, true);
+ };
- expect(findRadiosData()[0]).toEqual({
- value: SNIPPET_VISIBILITY_PRIVATE,
- icon: SNIPPET_VISIBILITY.private.icon,
- text: SNIPPET_VISIBILITY.private.label,
- description: SNIPPET_VISIBILITY.private.description_project,
+ it.each`
+ levels | resultOptions
+ ${undefined} | ${[]}
+ ${''} | ${[]}
+ ${[]} | ${[]}
+ ${[0]} | ${[RESULTING_OPTIONS[0]]}
+ ${[0, 10]} | ${[RESULTING_OPTIONS[0], RESULTING_OPTIONS[10]]}
+ ${[0, 10, 20]} | ${[RESULTING_OPTIONS[0], RESULTING_OPTIONS[10], RESULTING_OPTIONS[20]]}
+ ${[0, 20]} | ${[RESULTING_OPTIONS[0], RESULTING_OPTIONS[20]]}
+ ${[10, 20]} | ${[RESULTING_OPTIONS[10], RESULTING_OPTIONS[20]]}
+ `('renders correct visibility options for $levels', ({ levels, resultOptions }) => {
+ createComponent({ visibilityLevels: defaultSnippetVisibilityLevels(levels), deep: true });
+ expect(findRadiosData()).toEqual(resultOptions);
});
- });
- it('renders label help link', () => {
- createComponent();
-
- expect(
- findLabel()
- .find(GlLink)
- .attributes('href'),
- ).toBe(defaultHelpLink);
- });
+ it.each`
+ levels | levelsRestricted | resultText
+ ${[]} | ${false} | ${SNIPPET_LEVELS_DISABLED}
+ ${[]} | ${true} | ${SNIPPET_LEVELS_DISABLED}
+ ${[0]} | ${true} | ${SNIPPET_LEVELS_RESTRICTED}
+ ${[0]} | ${false} | ${''}
+ ${[0, 10, 20]} | ${false} | ${''}
+ `(
+ 'renders correct information about restricted visibility levels for $levels',
+ ({ levels, levelsRestricted, resultText }) => {
+ createComponent({
+ visibilityLevels: defaultSnippetVisibilityLevels(levels),
+ multipleLevelsRestricted: levelsRestricted,
+ });
+ expect(findRestrictedInfo().text()).toBe(resultText);
+ },
+ );
- it('when helpLink is not defined, does not render label help link', () => {
- createComponent({ helpLink: null });
+ it('when project snippet, renders special private description', () => {
+ createComponent({ propsData: { isProjectSnippet: true }, deep: true });
- expect(findLabel().contains(GlLink)).toBe(false);
+ expect(findRadiosData()[0]).toEqual({
+ value: SNIPPET_VISIBILITY_PRIVATE,
+ icon: SNIPPET_VISIBILITY.private.icon,
+ text: SNIPPET_VISIBILITY.private.label,
+ description: SNIPPET_VISIBILITY.private.description_project,
+ });
+ });
});
});
@@ -104,7 +156,7 @@ describe('Snippet Visibility Edit component', () => {
it('pre-selects correct option in the list', () => {
const value = SNIPPET_VISIBILITY_INTERNAL;
- createComponent({ value });
+ createComponent({ propsData: { value } });
expect(wrapper.find(GlFormRadioGroup).attributes('checked')).toBe(value);
});
diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js
index f4be911171e..7e90b53dd07 100644
--- a/spec/frontend/static_site_editor/components/edit_area_spec.js
+++ b/spec/frontend/static_site_editor/components/edit_area_spec.js
@@ -6,11 +6,13 @@ import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/consta
import EditArea from '~/static_site_editor/components/edit_area.vue';
import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
import EditHeader from '~/static_site_editor/components/edit_header.vue';
+import EditDrawer from '~/static_site_editor/components/edit_drawer.vue';
import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue';
import {
sourceContentTitle as title,
- sourceContent as content,
+ sourceContentYAML as content,
+ sourceContentHeaderObjYAML as headerSettings,
sourceContentBody as body,
returnUrl,
} from '../mock_data';
@@ -36,6 +38,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
};
const findEditHeader = () => wrapper.find(EditHeader);
+ const findEditDrawer = () => wrapper.find(EditDrawer);
const findRichContentEditor = () => wrapper.find(RichContentEditor);
const findPublishToolbar = () => wrapper.find(PublishToolbar);
const findUnsavedChangesConfirmDialog = () => wrapper.find(UnsavedChangesConfirmDialog);
@@ -46,6 +49,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
});
it('renders edit header', () => {
@@ -53,6 +57,10 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
expect(findEditHeader().props('title')).toBe(title);
});
+ it('renders edit drawer', () => {
+ expect(findEditDrawer().exists()).toBe(true);
+ });
+
it('renders rich content editor with a format pass', () => {
expect(findRichContentEditor().exists()).toBe(true);
expect(findRichContentEditor().props('content')).toBe(formattedBody);
@@ -81,7 +89,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
it('updates parsedSource with new content', () => {
const newContent = 'New content';
- const spySyncParsedSource = jest.spyOn(wrapper.vm.parsedSource, 'sync');
+ const spySyncParsedSource = jest.spyOn(wrapper.vm.parsedSource, 'syncContent');
findRichContentEditor().vm.$emit('input', newContent);
@@ -148,11 +156,88 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
});
});
+ describe('when content has front matter', () => {
+ it('renders a closed edit drawer', () => {
+ expect(findEditDrawer().exists()).toBe(true);
+ expect(findEditDrawer().props('isOpen')).toBe(false);
+ });
+
+ it('opens the edit drawer', () => {
+ findPublishToolbar().vm.$emit('editSettings');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findEditDrawer().props('isOpen')).toBe(true);
+ });
+ });
+
+ it('closes the edit drawer', () => {
+ findEditDrawer().vm.$emit('close');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findEditDrawer().props('isOpen')).toBe(false);
+ });
+ });
+
+ it('forwards the matter settings when the drawer is open', () => {
+ findPublishToolbar().vm.$emit('editSettings');
+
+ jest.spyOn(wrapper.vm.parsedSource, 'matter').mockReturnValueOnce(headerSettings);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findEditDrawer().props('settings')).toEqual(headerSettings);
+ });
+ });
+
+ it('enables toolbar submit button', () => {
+ expect(findPublishToolbar().props('hasSettings')).toBe(true);
+ });
+
+ it('syncs matter changes regardless of edit mode', () => {
+ const newSettings = { title: 'test' };
+ const spySyncParsedSource = jest.spyOn(wrapper.vm.parsedSource, 'syncMatter');
+
+ findEditDrawer().vm.$emit('updateSettings', newSettings);
+
+ expect(spySyncParsedSource).toHaveBeenCalledWith(newSettings);
+ });
+
+ it('syncs matter changes to content in markdown mode', () => {
+ wrapper.setData({ editorMode: EDITOR_TYPES.markdown });
+
+ const newSettings = { title: 'test' };
+
+ findEditDrawer().vm.$emit('updateSettings', newSettings);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findRichContentEditor().props('content')).toContain('title: test');
+ });
+ });
+ });
+
+ describe('when content lacks front matter', () => {
+ beforeEach(() => {
+ buildWrapper({ content: body });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('does not render edit drawer', () => {
+ expect(findEditDrawer().exists()).toBe(false);
+ });
+
+ it('does not enable toolbar submit button', () => {
+ expect(findPublishToolbar().props('hasSettings')).toBe(false);
+ });
+ });
+
describe('when content is submitted', () => {
it('should format the content', () => {
findPublishToolbar().vm.$emit('submit', content);
expect(wrapper.emitted('submit')[0][0].content).toBe(`${content} format-pass format-pass`);
+ expect(wrapper.emitted('submit').length).toBe(1);
});
});
});
diff --git a/spec/frontend/static_site_editor/components/edit_drawer_spec.js b/spec/frontend/static_site_editor/components/edit_drawer_spec.js
new file mode 100644
index 00000000000..c47eef59997
--- /dev/null
+++ b/spec/frontend/static_site_editor/components/edit_drawer_spec.js
@@ -0,0 +1,68 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { GlDrawer } from '@gitlab/ui';
+
+import EditDrawer from '~/static_site_editor/components/edit_drawer.vue';
+import FrontMatterControls from '~/static_site_editor/components/front_matter_controls.vue';
+
+describe('~/static_site_editor/components/edit_drawer.vue', () => {
+ let wrapper;
+
+ const buildWrapper = (propsData = {}) => {
+ wrapper = shallowMount(EditDrawer, {
+ propsData: {
+ isOpen: false,
+ settings: { title: 'Some title' },
+ ...propsData,
+ },
+ });
+ };
+
+ const findFrontMatterControls = () => wrapper.find(FrontMatterControls);
+ const findGlDrawer = () => wrapper.find(GlDrawer);
+
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders the GlDrawer', () => {
+ expect(findGlDrawer().exists()).toBe(true);
+ });
+
+ it('renders the FrontMatterControls', () => {
+ expect(findFrontMatterControls().exists()).toBe(true);
+ });
+
+ it('forwards the settings to FrontMatterControls', () => {
+ expect(findFrontMatterControls().props('settings')).toBe(wrapper.props('settings'));
+ });
+
+ it('is closed by default', () => {
+ expect(findGlDrawer().props('open')).toBe(false);
+ });
+
+ it('can open', () => {
+ buildWrapper({ isOpen: true });
+
+ expect(findGlDrawer().props('open')).toBe(true);
+ });
+
+ it.each`
+ event | payload | finderFn
+ ${'close'} | ${undefined} | ${findGlDrawer}
+ ${'updateSettings'} | ${{ some: 'data' }} | ${findFrontMatterControls}
+ `(
+ 'forwards the emitted $event event from the $finderFn with $payload',
+ ({ event, payload, finderFn }) => {
+ finderFn().vm.$emit(event, payload);
+
+ expect(wrapper.emitted(event)[0][0]).toBe(payload);
+ expect(wrapper.emitted(event).length).toBe(1);
+ },
+ );
+});
diff --git a/spec/frontend/static_site_editor/components/front_matter_controls_spec.js b/spec/frontend/static_site_editor/components/front_matter_controls_spec.js
new file mode 100644
index 00000000000..82e8fad643e
--- /dev/null
+++ b/spec/frontend/static_site_editor/components/front_matter_controls_spec.js
@@ -0,0 +1,78 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { GlFormGroup } from '@gitlab/ui';
+import { humanize } from '~/lib/utils/text_utility';
+
+import FrontMatterControls from '~/static_site_editor/components/front_matter_controls.vue';
+
+describe('~/static_site_editor/components/front_matter_controls.vue', () => {
+ let wrapper;
+
+ // TODO Refactor and update `sourceContentHeaderObjYAML` in mock_data when !41230 lands
+ const settings = {
+ layout: 'handbook-page-toc',
+ title: 'Handbook',
+ twitter_image: '/images/tweets/handbook-gitlab.png',
+ suppress_header: true,
+ extra_css: ['sales-and-free-trial-common.css', 'form-to-resource.css'],
+ };
+
+ const buildWrapper = (propsData = {}) => {
+ wrapper = shallowMount(FrontMatterControls, {
+ propsData: {
+ settings,
+ ...propsData,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render only the supported GlFormGroup types', () => {
+ expect(wrapper.findAll(GlFormGroup)).toHaveLength(3);
+ });
+
+ it.each`
+ key
+ ${'layout'}
+ ${'title'}
+ ${'twitter_image'}
+ `('renders field when key is $key', ({ key }) => {
+ const glFormGroup = wrapper.find(`#sse-front-matter-form-group-${key}`);
+ const glFormInput = wrapper.find(`#sse-front-matter-control-${key}`);
+
+ expect(glFormGroup.exists()).toBe(true);
+ expect(glFormGroup.attributes().label).toBe(humanize(key));
+
+ expect(glFormInput.exists()).toBe(true);
+ expect(glFormInput.attributes().value).toBe(settings[key]);
+ });
+
+ it.each`
+ key
+ ${'suppress_header'}
+ ${'extra_css'}
+ `('does not render field when key is $key', ({ key }) => {
+ const glFormInput = wrapper.find(`#sse-front-matter-control-${key}`);
+
+ expect(glFormInput.exists()).toBe(false);
+ });
+
+ it('emits updated settings when nested control updates', () => {
+ const elId = `#sse-front-matter-control-title`;
+ const glFormInput = wrapper.find(elId);
+ const newTitle = 'New title';
+
+ glFormInput.vm.$emit('input', newTitle);
+
+ const newSettings = { ...settings, title: newTitle };
+
+ expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings);
+ });
+});
diff --git a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js b/spec/frontend/static_site_editor/components/publish_toolbar_spec.js
index 5428ed23266..9ba7e4a94d1 100644
--- a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js
+++ b/spec/frontend/static_site_editor/components/publish_toolbar_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import { GlButton } from '@gitlab/ui';
import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
@@ -11,6 +10,7 @@ describe('Static Site Editor Toolbar', () => {
const buildWrapper = (propsData = {}) => {
wrapper = shallowMount(PublishToolbar, {
propsData: {
+ hasSettings: false,
saveable: false,
...propsData,
},
@@ -18,7 +18,8 @@ describe('Static Site Editor Toolbar', () => {
};
const findReturnUrlLink = () => wrapper.find({ ref: 'returnUrlLink' });
- const findSaveChangesButton = () => wrapper.find(GlButton);
+ const findSaveChangesButton = () => wrapper.find({ ref: 'submit' });
+ const findEditSettingsButton = () => wrapper.find({ ref: 'settings' });
beforeEach(() => {
buildWrapper();
@@ -28,6 +29,10 @@ describe('Static Site Editor Toolbar', () => {
wrapper.destroy();
});
+ it('does not render Settings button', () => {
+ expect(findEditSettingsButton().exists()).toBe(false);
+ });
+
it('renders Submit Changes button', () => {
expect(findSaveChangesButton().exists()).toBe(true);
});
@@ -51,6 +56,14 @@ describe('Static Site Editor Toolbar', () => {
expect(findReturnUrlLink().attributes('href')).toBe(returnUrl);
});
+ describe('when providing settings CTA', () => {
+ it('enables Submit Changes button', () => {
+ buildWrapper({ hasSettings: true });
+
+ expect(findEditSettingsButton().exists()).toBe(true);
+ });
+ });
+
describe('when saveable', () => {
it('enables Submit Changes button', () => {
buildWrapper({ saveable: true });
diff --git a/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js
index 8504d09e0f1..24651543650 100644
--- a/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js
+++ b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js
@@ -5,7 +5,7 @@ import {
projectId,
sourcePath,
sourceContentTitle as title,
- sourceContent as content,
+ sourceContentYAML as content,
} from '../../mock_data';
jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn());
diff --git a/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js
index 515b5394594..750b777cf5d 100644
--- a/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js
+++ b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js
@@ -6,7 +6,7 @@ import {
projectId as project,
sourcePath,
username,
- sourceContent as content,
+ sourceContentYAML as content,
savedContentMeta,
} from '../../mock_data';
diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js
index 96de9b73af0..d861f6c9cd7 100644
--- a/spec/frontend/static_site_editor/mock_data.js
+++ b/spec/frontend/static_site_editor/mock_data.js
@@ -1,19 +1,22 @@
-export const sourceContentHeader = `---
+export const sourceContentHeaderYAML = `---
layout: handbook-page-toc
title: Handbook
-twitter_image: '/images/tweets/handbook-gitlab.png'
+twitter_image: /images/tweets/handbook-gitlab.png
---`;
-export const sourceContentSpacing = `
-`;
+export const sourceContentHeaderObjYAML = {
+ layout: 'handbook-page-toc',
+ title: 'Handbook',
+ twitter_image: '/images/tweets/handbook-gitlab.png',
+};
+export const sourceContentSpacing = `\n`;
export const sourceContentBody = `## On this page
{:.no_toc .hidden-md .hidden-lg}
- TOC
{:toc .hidden-md .hidden-lg}
-![image](path/to/image1.png)
-`;
-export const sourceContent = `${sourceContentHeader}${sourceContentSpacing}${sourceContentBody}`;
+![image](path/to/image1.png)`;
+export const sourceContentYAML = `${sourceContentHeaderYAML}${sourceContentSpacing}${sourceContentBody}`;
export const sourceContentTitle = 'Handbook';
export const username = 'gitlabuser';
diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js
index c5473596df8..41f8a1075c0 100644
--- a/spec/frontend/static_site_editor/pages/home_spec.js
+++ b/spec/frontend/static_site_editor/pages/home_spec.js
@@ -13,7 +13,7 @@ import { TRACKING_ACTION_INITIALIZE_EDITOR } from '~/static_site_editor/constant
import {
projectId as project,
returnUrl,
- sourceContent as content,
+ sourceContentYAML as content,
sourceContentTitle as title,
sourcePath,
username,
diff --git a/spec/frontend/static_site_editor/services/formatter_spec.js b/spec/frontend/static_site_editor/services/formatter_spec.js
index b7600798db9..9e9c4bbd171 100644
--- a/spec/frontend/static_site_editor/services/formatter_spec.js
+++ b/spec/frontend/static_site_editor/services/formatter_spec.js
@@ -1,6 +1,6 @@
import formatter from '~/static_site_editor/services/formatter';
-describe('formatter', () => {
+describe('static_site_editor/services/formatter', () => {
const source = `Some text
<br>
@@ -23,4 +23,17 @@ And even more text`;
it('removes extraneous <br> tags', () => {
expect(formatter(source)).toMatch(sourceWithoutBrTags);
});
+
+ describe('ordered lists with incorrect content indentation', () => {
+ it.each`
+ input | result
+ ${'12. ordered list item\n13.Next ordered list item'} | ${'12. ordered list item\n13.Next ordered list item'}
+ ${'12. ordered list item\n - Next ordered list item'} | ${'12. ordered list item\n - Next ordered list item'}
+ ${'12. ordered list item\n - Next ordered list item'} | ${'12. ordered list item\n - Next ordered list item'}
+ ${'12. ordered list item\n Next ordered list item'} | ${'12. ordered list item\n Next ordered list item'}
+ ${'1. ordered list item\n Next ordered list item'} | ${'1. ordered list item\n Next ordered list item'}
+ `('\ntransforms\n$input \nto\n$result', ({ input, result }) => {
+ expect(formatter(input)).toBe(result);
+ });
+ });
});
diff --git a/spec/frontend/static_site_editor/services/load_source_content_spec.js b/spec/frontend/static_site_editor/services/load_source_content_spec.js
index 87893bb7a6e..54061b7a503 100644
--- a/spec/frontend/static_site_editor/services/load_source_content_spec.js
+++ b/spec/frontend/static_site_editor/services/load_source_content_spec.js
@@ -2,7 +2,12 @@ import Api from '~/api';
import loadSourceContent from '~/static_site_editor/services/load_source_content';
-import { sourceContent, sourceContentTitle, projectId, sourcePath } from '../mock_data';
+import {
+ sourceContentYAML as sourceContent,
+ sourceContentTitle,
+ projectId,
+ sourcePath,
+} from '../mock_data';
describe('loadSourceContent', () => {
describe('requesting source content succeeds', () => {
diff --git a/spec/frontend/static_site_editor/services/parse_source_file_spec.js b/spec/frontend/static_site_editor/services/parse_source_file_spec.js
index 4588548e614..ab9e63f4cd2 100644
--- a/spec/frontend/static_site_editor/services/parse_source_file_spec.js
+++ b/spec/frontend/static_site_editor/services/parse_source_file_spec.js
@@ -1,14 +1,29 @@
-import { sourceContent as content, sourceContentBody as body } from '../mock_data';
+import {
+ sourceContentYAML as content,
+ sourceContentHeaderYAML as yamlFrontMatter,
+ sourceContentHeaderObjYAML as yamlFrontMatterObj,
+ sourceContentBody as body,
+} from '../mock_data';
import parseSourceFile from '~/static_site_editor/services/parse_source_file';
-describe('parseSourceFile', () => {
+describe('static_site_editor/services/parse_source_file', () => {
const contentComplex = [content, content, content].join('');
const complexBody = [body, content, content].join('');
const edit = 'and more';
const newContent = `${content} ${edit}`;
const newContentComplex = `${contentComplex} ${edit}`;
+ describe('unmodified front matter', () => {
+ it.each`
+ parsedSource
+ ${parseSourceFile(content)}
+ ${parseSourceFile(contentComplex)}
+ `('returns $targetFrontMatter when frontMatter queried', ({ parsedSource }) => {
+ expect(parsedSource.matter()).toEqual(yamlFrontMatterObj);
+ });
+ });
+
describe('unmodified content', () => {
it.each`
parsedSource
@@ -34,21 +49,50 @@ describe('parseSourceFile', () => {
);
});
+ describe('modified front matter', () => {
+ const newYamlFrontMatter = '---\nnewKey: newVal\n---';
+ const newYamlFrontMatterObj = { newKey: 'newVal' };
+ const contentWithNewFrontMatter = content.replace(yamlFrontMatter, newYamlFrontMatter);
+ const contentComplexWithNewFrontMatter = contentComplex.replace(
+ yamlFrontMatter,
+ newYamlFrontMatter,
+ );
+
+ it.each`
+ parsedSource | targetContent
+ ${parseSourceFile(content)} | ${contentWithNewFrontMatter}
+ ${parseSourceFile(contentComplex)} | ${contentComplexWithNewFrontMatter}
+ `(
+ 'returns the correct front matter and modified content',
+ ({ parsedSource, targetContent }) => {
+ expect(parsedSource.matter()).toMatchObject(yamlFrontMatterObj);
+
+ parsedSource.syncMatter(newYamlFrontMatterObj);
+
+ expect(parsedSource.matter()).toMatchObject(newYamlFrontMatterObj);
+ expect(parsedSource.content()).toBe(targetContent);
+ },
+ );
+ });
+
describe('modified content', () => {
const newBody = `${body} ${edit}`;
const newComplexBody = `${complexBody} ${edit}`;
it.each`
- parsedSource | isModified | targetRaw | targetBody
- ${parseSourceFile(content)} | ${false} | ${content} | ${body}
- ${parseSourceFile(content)} | ${true} | ${newContent} | ${newBody}
- ${parseSourceFile(contentComplex)} | ${false} | ${contentComplex} | ${complexBody}
- ${parseSourceFile(contentComplex)} | ${true} | ${newContentComplex} | ${newComplexBody}
+ parsedSource | hasMatter | isModified | targetRaw | targetBody
+ ${parseSourceFile(content)} | ${true} | ${false} | ${content} | ${body}
+ ${parseSourceFile(content)} | ${true} | ${true} | ${newContent} | ${newBody}
+ ${parseSourceFile(contentComplex)} | ${true} | ${false} | ${contentComplex} | ${complexBody}
+ ${parseSourceFile(contentComplex)} | ${true} | ${true} | ${newContentComplex} | ${newComplexBody}
+ ${parseSourceFile(body)} | ${false} | ${false} | ${body} | ${body}
+ ${parseSourceFile(body)} | ${false} | ${true} | ${newBody} | ${newBody}
`(
'returns $isModified after a $targetRaw sync',
- ({ parsedSource, isModified, targetRaw, targetBody }) => {
- parsedSource.sync(targetRaw);
+ ({ parsedSource, hasMatter, isModified, targetRaw, targetBody }) => {
+ parsedSource.syncContent(targetRaw);
+ expect(parsedSource.hasMatter()).toBe(hasMatter);
expect(parsedSource.isModified()).toBe(isModified);
expect(parsedSource.content()).toBe(targetRaw);
expect(parsedSource.content(true)).toBe(targetBody);
diff --git a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js
index 645ccedf7e7..d464e6b1895 100644
--- a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js
+++ b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js
@@ -20,7 +20,7 @@ import {
commitMultipleResponse,
createMergeRequestResponse,
sourcePath,
- sourceContent as content,
+ sourceContentYAML as content,
trackingCategory,
images,
} from '../mock_data';
diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js
new file mode 100644
index 00000000000..0edc5248629
--- /dev/null
+++ b/spec/frontend/tooltips/components/tooltips_spec.js
@@ -0,0 +1,202 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlTooltip } from '@gitlab/ui';
+import { useMockMutationObserver } from 'helpers/mock_dom_observer';
+import Tooltips from '~/tooltips/components/tooltips.vue';
+
+describe('tooltips/components/tooltips.vue', () => {
+ const { trigger: triggerMutate, observersCount } = useMockMutationObserver();
+ let wrapper;
+
+ const buildWrapper = () => {
+ wrapper = shallowMount(Tooltips);
+ };
+
+ const createTooltipTarget = (attributes = {}) => {
+ const target = document.createElement('button');
+ const defaults = {
+ title: 'default title',
+ ...attributes,
+ };
+
+ Object.keys(defaults).forEach(name => {
+ target.setAttribute(name, defaults[name]);
+ });
+
+ document.body.appendChild(target);
+
+ return target;
+ };
+
+ const allTooltips = () => wrapper.findAll(GlTooltip);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('addTooltips', () => {
+ let target;
+
+ beforeEach(() => {
+ buildWrapper();
+
+ target = createTooltipTarget();
+ });
+
+ it('attaches tooltips to the targets specified', async () => {
+ wrapper.vm.addTooltips([target]);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlTooltip).props('target')).toBe(target);
+ });
+
+ it('does not attach a tooltip twice to the same element', async () => {
+ wrapper.vm.addTooltips([target]);
+ wrapper.vm.addTooltips([target]);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findAll(GlTooltip)).toHaveLength(1);
+ });
+
+ it('sets tooltip content from title attribute', async () => {
+ wrapper.vm.addTooltips([target]);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlTooltip).text()).toBe(target.getAttribute('title'));
+ });
+
+ it('supports HTML content', async () => {
+ target = createTooltipTarget({
+ title: 'content with <b>HTML</b>',
+ 'data-html': true,
+ });
+ wrapper.vm.addTooltips([target]);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlTooltip).html()).toContain(target.getAttribute('title'));
+ });
+
+ it.each`
+ attribute | value | prop
+ ${'data-placement'} | ${'bottom'} | ${'placement'}
+ ${'data-container'} | ${'custom-container'} | ${'container'}
+ ${'data-boundary'} | ${'viewport'} | ${'boundary'}
+ ${'data-triggers'} | ${'manual'} | ${'triggers'}
+ `(
+ 'sets $prop to $value when $attribute is set in target',
+ async ({ attribute, value, prop }) => {
+ target = createTooltipTarget({ [attribute]: value });
+ wrapper.vm.addTooltips([target]);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlTooltip).props(prop)).toBe(value);
+ },
+ );
+ });
+
+ describe('dispose', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('removes all tooltips when elements is nil', async () => {
+ wrapper.vm.addTooltips([createTooltipTarget(), createTooltipTarget()]);
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.dispose();
+ await wrapper.vm.$nextTick();
+
+ expect(allTooltips()).toHaveLength(0);
+ });
+
+ it('removes the tooltips that target the elements specified', async () => {
+ const target = createTooltipTarget();
+
+ wrapper.vm.addTooltips([target, createTooltipTarget()]);
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.dispose(target);
+ await wrapper.vm.$nextTick();
+
+ expect(allTooltips()).toHaveLength(1);
+ });
+ });
+
+ describe('observe', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('removes tooltip when target is removed from the document', async () => {
+ const target = createTooltipTarget();
+
+ wrapper.vm.addTooltips([target, createTooltipTarget()]);
+ await wrapper.vm.$nextTick();
+
+ triggerMutate(document.body, {
+ entry: { removedNodes: [target] },
+ options: { childList: true },
+ });
+ await wrapper.vm.$nextTick();
+
+ expect(allTooltips()).toHaveLength(1);
+ });
+ });
+
+ describe('triggerEvent', () => {
+ it('triggers a bootstrap-vue tooltip global event for the tooltip specified', async () => {
+ const target = createTooltipTarget();
+ const event = 'hide';
+
+ buildWrapper();
+
+ wrapper.vm.addTooltips([target]);
+
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.triggerEvent(target, event);
+
+ expect(wrapper.find(GlTooltip).emitted(event)).toHaveLength(1);
+ });
+ });
+
+ describe('fixTitle', () => {
+ it('updates tooltip content with the latest value the target title property', async () => {
+ const target = createTooltipTarget();
+ const currentTitle = 'title';
+ const newTitle = 'new title';
+
+ target.setAttribute('title', currentTitle);
+
+ buildWrapper();
+
+ wrapper.vm.addTooltips([target]);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlTooltip).text()).toBe(currentTitle);
+
+ target.setAttribute('title', newTitle);
+ wrapper.vm.fixTitle(target);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlTooltip).text()).toBe(newTitle);
+ });
+ });
+
+ it('disconnects mutation observer on beforeDestroy', () => {
+ buildWrapper();
+ wrapper.vm.addTooltips([createTooltipTarget()]);
+
+ expect(observersCount()).toBe(1);
+
+ wrapper.destroy();
+ expect(observersCount()).toBe(0);
+ });
+});
diff --git a/spec/frontend/tooltips/index_spec.js b/spec/frontend/tooltips/index_spec.js
new file mode 100644
index 00000000000..cc72adee57d
--- /dev/null
+++ b/spec/frontend/tooltips/index_spec.js
@@ -0,0 +1,149 @@
+import jQuery from 'jquery';
+import { initTooltips, dispose, destroy, hide, show, enable, disable, fixTitle } from '~/tooltips';
+
+describe('tooltips/index.js', () => {
+ let tooltipsApp;
+
+ const createTooltipTarget = () => {
+ const target = document.createElement('button');
+ const attributes = {
+ title: 'default title',
+ };
+
+ Object.keys(attributes).forEach(name => {
+ target.setAttribute(name, attributes[name]);
+ });
+
+ target.classList.add('has-tooltip');
+
+ document.body.appendChild(target);
+
+ return target;
+ };
+
+ const buildTooltipsApp = () => {
+ tooltipsApp = initTooltips({ selector: '.has-tooltip' });
+ };
+
+ const triggerEvent = (target, eventName = 'mouseenter') => {
+ const event = new Event(eventName);
+
+ target.dispatchEvent(event);
+ };
+
+ beforeEach(() => {
+ window.gon.glTooltipsEnabled = true;
+ });
+
+ afterEach(() => {
+ document.body.childNodes.forEach(node => node.remove());
+ destroy();
+ });
+
+ describe('initTooltip', () => {
+ it('attaches a GlTooltip for the elements specified in the selector', async () => {
+ const target = createTooltipTarget();
+
+ buildTooltipsApp();
+
+ triggerEvent(target);
+
+ await tooltipsApp.$nextTick();
+
+ expect(document.querySelector('.gl-tooltip')).not.toBe(null);
+ expect(document.querySelector('.gl-tooltip').innerHTML).toContain('default title');
+ });
+
+ it('supports triggering a tooltip in custom events', async () => {
+ const target = createTooltipTarget();
+
+ buildTooltipsApp();
+ triggerEvent(target, 'click');
+
+ await tooltipsApp.$nextTick();
+
+ expect(document.querySelector('.gl-tooltip')).not.toBe(null);
+ expect(document.querySelector('.gl-tooltip').innerHTML).toContain('default title');
+ });
+ });
+
+ describe('dispose', () => {
+ it('removes tooltips that target the elements specified', async () => {
+ const target = createTooltipTarget();
+
+ buildTooltipsApp();
+ triggerEvent(target);
+
+ await tooltipsApp.$nextTick();
+
+ expect(document.querySelector('.gl-tooltip')).not.toBe(null);
+
+ dispose([target]);
+
+ await tooltipsApp.$nextTick();
+
+ expect(document.querySelector('.gl-tooltip')).toBe(null);
+ });
+ });
+
+ it.each`
+ methodName | method | event
+ ${'enable'} | ${enable} | ${'enable'}
+ ${'disable'} | ${disable} | ${'disable'}
+ ${'hide'} | ${hide} | ${'close'}
+ ${'show'} | ${show} | ${'open'}
+ `(
+ '$methodName calls triggerEvent in tooltip app with $event event',
+ async ({ method, event }) => {
+ const target = createTooltipTarget();
+
+ buildTooltipsApp();
+
+ await tooltipsApp.$nextTick();
+
+ jest.spyOn(tooltipsApp, 'triggerEvent');
+
+ method([target]);
+
+ expect(tooltipsApp.triggerEvent).toHaveBeenCalledWith(target, event);
+ },
+ );
+
+ it('fixTitle calls fixTitle in tooltip app with the target specified', async () => {
+ const target = createTooltipTarget();
+
+ buildTooltipsApp();
+
+ await tooltipsApp.$nextTick();
+
+ jest.spyOn(tooltipsApp, 'fixTitle');
+
+ fixTitle([target]);
+
+ expect(tooltipsApp.fixTitle).toHaveBeenCalledWith(target);
+ });
+
+ describe('when glTooltipsEnabled feature flag is disabled', () => {
+ beforeEach(() => {
+ window.gon.glTooltipsEnabled = false;
+ });
+
+ it.each`
+ method | methodName | bootstrapParams
+ ${dispose} | ${'dispose'} | ${'dispose'}
+ ${fixTitle} | ${'fixTitle'} | ${'_fixTitle'}
+ ${enable} | ${'enable'} | ${'enable'}
+ ${disable} | ${'disable'} | ${'disable'}
+ ${hide} | ${'hide'} | ${'hide'}
+ ${show} | ${'show'} | ${'show'}
+ `('delegates $methodName to bootstrap tooltip API', ({ method, bootstrapParams }) => {
+ const elements = jQuery(createTooltipTarget());
+
+ jest.spyOn(jQuery.fn, 'tooltip');
+
+ method(elements);
+
+ expect(elements.tooltip).toHaveBeenCalledWith(bootstrapParams);
+ });
+ });
+});
diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js
index 8acfa655c2c..e2d39ffeaf0 100644
--- a/spec/frontend/tracking_spec.js
+++ b/spec/frontend/tracking_spec.js
@@ -1,5 +1,5 @@
import { setHTMLFixture } from './helpers/fixtures';
-import Tracking, { initUserTracking } from '~/tracking';
+import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking';
describe('Tracking', () => {
let snowplowSpy;
@@ -17,11 +17,6 @@ describe('Tracking', () => {
});
describe('initUserTracking', () => {
- beforeEach(() => {
- bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null);
- trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null);
- });
-
it('calls through to get a new tracker with the expected options', () => {
initUserTracking();
expect(snowplowSpy).toHaveBeenCalledWith('newTracker', '_namespace_', 'app.gitfoo.com', {
@@ -38,9 +33,16 @@ describe('Tracking', () => {
linkClickTracking: false,
});
});
+ });
+
+ describe('initDefaultTrackers', () => {
+ beforeEach(() => {
+ bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null);
+ trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null);
+ });
it('should activate features based on what has been enabled', () => {
- initUserTracking();
+ initDefaultTrackers();
expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30);
expect(snowplowSpy).toHaveBeenCalledWith('trackPageView');
expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking');
@@ -52,18 +54,18 @@ describe('Tracking', () => {
linkClickTracking: true,
};
- initUserTracking();
+ initDefaultTrackers();
expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking');
expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking');
});
it('binds the document event handling', () => {
- initUserTracking();
+ initDefaultTrackers();
expect(bindDocumentSpy).toHaveBeenCalled();
});
it('tracks page loaded events', () => {
- initUserTracking();
+ initDefaultTrackers();
expect(trackLoadEventsSpy).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/vue_mr_widget/components/mock_data.js b/spec/frontend/vue_mr_widget/components/mock_data.js
index 39c7d75cda5..73e254f2b1a 100644
--- a/spec/frontend/vue_mr_widget/components/mock_data.js
+++ b/spec/frontend/vue_mr_widget/components/mock_data.js
@@ -1,4 +1,3 @@
-// eslint-disable-next-line import/prefer-default-export
export const artifactsList = [
{
text: 'result.txt',
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js
index 60f970e0018..4e3e918f7fb 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js
@@ -20,8 +20,8 @@ describe('MrWidgetContainer', () => {
it('has layout', () => {
factory();
- expect(wrapper.is('.mr-widget-heading')).toBe(true);
- expect(wrapper.contains('.mr-widget-content')).toBe(true);
+ expect(wrapper.classes()).toContain('mr-widget-heading');
+ expect(wrapper.find('.mr-widget-content').exists()).toBe(true);
});
it('accepts default slot', () => {
@@ -31,7 +31,7 @@ describe('MrWidgetContainer', () => {
},
});
- expect(wrapper.contains('.mr-widget-content .test-body')).toBe(true);
+ expect(wrapper.find('.mr-widget-content .test-body').exists()).toBe(true);
});
it('accepts footer slot', () => {
@@ -42,7 +42,7 @@ describe('MrWidgetContainer', () => {
},
});
- expect(wrapper.contains('.mr-widget-content .test-body')).toBe(true);
- expect(wrapper.contains('.test-footer')).toBe(true);
+ expect(wrapper.find('.mr-widget-content .test-body').exists()).toBe(true);
+ expect(wrapper.find('.test-footer').exists()).toBe(true);
});
});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js
index 69a50899d4d..3e111cd308a 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js
@@ -18,7 +18,7 @@ describe('MrWidgetExpanableSection', () => {
});
it('renders Icon', () => {
- expect(wrapper.contains(GlIcon)).toBe(true);
+ expect(wrapper.find(GlIcon).exists()).toBe(true);
});
it('renders header slot', () => {
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
index 21058005d29..caea9a757ae 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
@@ -25,10 +25,14 @@ describe('MRWidgetHeader', () => {
const downloadEmailPatchesEl = vm.$el.querySelector('.js-download-email-patches');
const downloadPlainDiffEl = vm.$el.querySelector('.js-download-plain-diff');
- expect(downloadEmailPatchesEl.textContent.trim()).toEqual('Email patches');
- expect(downloadEmailPatchesEl.getAttribute('href')).toEqual('/mr/email-patches');
- expect(downloadPlainDiffEl.textContent.trim()).toEqual('Plain diff');
- expect(downloadPlainDiffEl.getAttribute('href')).toEqual('/mr/plainDiffPath');
+ expect(downloadEmailPatchesEl.innerText.trim()).toEqual('Email patches');
+ expect(downloadEmailPatchesEl.querySelector('a').getAttribute('href')).toEqual(
+ '/mr/email-patches',
+ );
+ expect(downloadPlainDiffEl.innerText.trim()).toEqual('Plain diff');
+ expect(downloadPlainDiffEl.querySelector('a').getAttribute('href')).toEqual(
+ '/mr/plainDiffPath',
+ );
};
describe('computed', () => {
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js
index cee0b9b0118..ea8b33495ab 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue';
-import Icon from '~/vue_shared/components/icon.vue';
const TEST_ICON = 'commit';
@@ -21,6 +21,6 @@ describe('MrWidgetIcon', () => {
it('renders icon and container', () => {
expect(wrapper.is('.circle-icon-container')).toBe(true);
- expect(wrapper.find(Icon).props('name')).toEqual(TEST_ICON);
+ expect(wrapper.find(GlIcon).props('name')).toEqual(TEST_ICON);
});
});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js
index 6486826c3ec..7ecd8629607 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js
@@ -29,6 +29,8 @@ describe('MRWidgetPipeline', () => {
const findAllPipelineStages = () => wrapper.findAll(PipelineStage);
const findPipelineCoverage = () => wrapper.find('[data-testid="pipeline-coverage"]');
const findPipelineCoverageDelta = () => wrapper.find('[data-testid="pipeline-coverage-delta"]');
+ const findPipelineCoverageTooltipText = () =>
+ wrapper.find('[data-testid="pipeline-coverage-tooltip"]').text();
const findMonitoringPipelineMessage = () =>
wrapper.find('[data-testid="monitoring-pipeline-message"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
@@ -49,257 +51,208 @@ describe('MRWidgetPipeline', () => {
}
});
- describe('computed', () => {
- describe('hasPipeline', () => {
- beforeEach(() => {
- createWrapper();
- });
-
- it('should return true when there is a pipeline', () => {
- expect(wrapper.vm.hasPipeline).toBe(true);
- });
+ it('should render CI error if there is a pipeline, but no status', () => {
+ createWrapper({ ciStatus: null }, mount);
+ expect(findCIErrorMessage().text()).toBe(ciErrorMessage);
+ });
- it('should return false when there is no pipeline', async () => {
- wrapper.setProps({ pipeline: {} });
+ it('should render a loading state when no pipeline is found', () => {
+ createWrapper({ pipeline: {} }, mount);
- await wrapper.vm.$nextTick();
+ expect(findMonitoringPipelineMessage().text()).toBe(monitoringMessage);
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
- expect(wrapper.vm.hasPipeline).toBe(false);
+ describe('with a pipeline', () => {
+ beforeEach(() => {
+ createWrapper({
+ pipelineCoverageDelta: mockData.pipelineCoverageDelta,
+ buildsWithCoverage: mockData.buildsWithCoverage,
});
});
- describe('hasCIError', () => {
- beforeEach(() => {
- createWrapper();
- });
+ it('should render pipeline ID', () => {
+ expect(
+ findPipelineID()
+ .text()
+ .trim(),
+ ).toBe(`#${mockData.pipeline.id}`);
+ });
- it('should return false when there is no CI error', () => {
- expect(wrapper.vm.hasCIError).toBe(false);
- });
+ it('should render pipeline status and commit id', () => {
+ expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label);
- it('should return true when there is a pipeline, but no ci status', async () => {
- wrapper.setProps({ ciStatus: null });
+ expect(
+ findCommitLink()
+ .text()
+ .trim(),
+ ).toBe(mockData.pipeline.commit.short_id);
- await wrapper.vm.$nextTick();
+ expect(findCommitLink().attributes('href')).toBe(mockData.pipeline.commit.commit_path);
+ });
- expect(wrapper.vm.hasCIError).toBe(true);
- });
+ it('should render pipeline graph', () => {
+ expect(findPipelineGraph().exists()).toBe(true);
+ expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length);
});
- describe('coverageDeltaClass', () => {
- beforeEach(() => {
- createWrapper({ pipelineCoverageDelta: '0' });
+ describe('should render pipeline coverage information', () => {
+ it('should render coverage percentage', () => {
+ expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`);
});
- it('should return no class if there is no coverage change', async () => {
- expect(wrapper.vm.coverageDeltaClass).toBe('');
+ it('should render coverage delta', () => {
+ expect(findPipelineCoverageDelta().exists()).toBe(true);
+ expect(findPipelineCoverageDelta().text()).toBe(`(${mockData.pipelineCoverageDelta}%)`);
});
- it('should return text-success if the coverage increased', async () => {
- wrapper.setProps({ pipelineCoverageDelta: '10' });
-
- await wrapper.vm.$nextTick();
+ it('coverage delta should have no special style if there is no coverage change', () => {
+ createWrapper({ pipelineCoverageDelta: '0' });
+ expect(findPipelineCoverageDelta().classes()).toEqual([]);
+ });
- expect(wrapper.vm.coverageDeltaClass).toBe('text-success');
+ it('coverage delta should have text-success style if coverage increased', () => {
+ createWrapper({ pipelineCoverageDelta: '10' });
+ expect(findPipelineCoverageDelta().classes()).toEqual(['text-success']);
});
- it('should return text-danger if the coverage decreased', async () => {
- wrapper.setProps({ pipelineCoverageDelta: '-12' });
+ it('coverage delta should have text-danger style if coverage increased', () => {
+ createWrapper({ pipelineCoverageDelta: '-10' });
+ expect(findPipelineCoverageDelta().classes()).toEqual(['text-danger']);
+ });
- await wrapper.vm.$nextTick();
+ it('should render tooltip for jobs contributing to code coverage', () => {
+ const tooltipText = findPipelineCoverageTooltipText();
+ const expectedDescription = `Coverage value for this pipeline was calculated by averaging the resulting coverage values of ${mockData.buildsWithCoverage.length} jobs.`;
- expect(wrapper.vm.coverageDeltaClass).toBe('text-danger');
+ expect(tooltipText).toContain(expectedDescription);
});
+
+ it.each(mockData.buildsWithCoverage)(
+ 'should have name and coverage for build %s listed in tooltip',
+ build => {
+ const tooltipText = findPipelineCoverageTooltipText();
+
+ expect(tooltipText).toContain(`${build.name} (${build.coverage}%)`);
+ },
+ );
});
});
- describe('rendered output', () => {
+ describe('without commit path', () => {
beforeEach(() => {
- createWrapper({ ciStatus: null }, mount);
- });
+ const mockCopy = JSON.parse(JSON.stringify(mockData));
+ delete mockCopy.pipeline.commit;
- it('should render CI error if there is a pipeline, but no status', async () => {
- expect(findCIErrorMessage().text()).toBe(ciErrorMessage);
+ createWrapper({});
});
- it('should render a loading state when no pipeline is found', async () => {
- wrapper.setProps({
- pipeline: {},
- hasCi: false,
- pipelineMustSucceed: true,
- });
-
- await wrapper.vm.$nextTick();
-
- expect(findMonitoringPipelineMessage().text()).toBe(monitoringMessage);
- expect(findLoadingIcon().exists()).toBe(true);
+ it('should render pipeline ID', () => {
+ expect(
+ findPipelineID()
+ .text()
+ .trim(),
+ ).toBe(`#${mockData.pipeline.id}`);
});
- describe('with a pipeline', () => {
- beforeEach(() => {
- createWrapper(
- {
- pipelineCoverageDelta: mockData.pipelineCoverageDelta,
- },
- mount,
- );
- });
-
- it('should render pipeline ID', () => {
- expect(
- findPipelineID()
- .text()
- .trim(),
- ).toBe(`#${mockData.pipeline.id}`);
- });
-
- it('should render pipeline status and commit id', () => {
- expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label);
-
- expect(
- findCommitLink()
- .text()
- .trim(),
- ).toBe(mockData.pipeline.commit.short_id);
-
- expect(findCommitLink().attributes('href')).toBe(mockData.pipeline.commit.commit_path);
- });
-
- it('should render pipeline graph', () => {
- expect(findPipelineGraph().exists()).toBe(true);
- expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length);
- });
-
- it('should render coverage information', () => {
- expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`);
- });
+ it('should render pipeline status', () => {
+ expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label);
+ });
- it('should render pipeline coverage delta information', () => {
- expect(findPipelineCoverageDelta().exists()).toBe(true);
- expect(findPipelineCoverageDelta().text()).toBe(`(${mockData.pipelineCoverageDelta}%)`);
- });
+ it('should render pipeline graph', () => {
+ expect(findPipelineGraph().exists()).toBe(true);
+ expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length);
});
- describe('without commit path', () => {
- beforeEach(() => {
- const mockCopy = JSON.parse(JSON.stringify(mockData));
- delete mockCopy.pipeline.commit;
+ it('should render coverage information', () => {
+ expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`);
+ });
+ });
- createWrapper({}, mount);
- });
+ describe('without coverage', () => {
+ beforeEach(() => {
+ const mockCopy = JSON.parse(JSON.stringify(mockData));
+ delete mockCopy.pipeline.coverage;
- it('should render pipeline ID', () => {
- expect(
- findPipelineID()
- .text()
- .trim(),
- ).toBe(`#${mockData.pipeline.id}`);
- });
+ createWrapper({ pipeline: mockCopy.pipeline });
+ });
- it('should render pipeline status', () => {
- expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label);
- });
+ it('should not render a coverage component', () => {
+ expect(findPipelineCoverage().exists()).toBe(false);
+ });
+ });
- it('should render pipeline graph', () => {
- expect(findPipelineGraph().exists()).toBe(true);
- expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length);
- });
+ describe('without a pipeline graph', () => {
+ beforeEach(() => {
+ const mockCopy = JSON.parse(JSON.stringify(mockData));
+ delete mockCopy.pipeline.details.stages;
- it('should render coverage information', () => {
- expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`);
+ createWrapper({
+ pipeline: mockCopy.pipeline,
});
});
- describe('without coverage', () => {
- beforeEach(() => {
- const mockCopy = JSON.parse(JSON.stringify(mockData));
- delete mockCopy.pipeline.coverage;
-
- createWrapper(
- {
- pipeline: mockCopy.pipeline,
- },
- mount,
- );
- });
-
- it('should not render a coverage component', () => {
- expect(findPipelineCoverage().exists()).toBe(false);
- });
+ it('should not render a pipeline graph', () => {
+ expect(findPipelineGraph().exists()).toBe(false);
});
+ });
- describe('without a pipeline graph', () => {
- beforeEach(() => {
- const mockCopy = JSON.parse(JSON.stringify(mockData));
- delete mockCopy.pipeline.details.stages;
+ describe('for each type of pipeline', () => {
+ let pipeline;
- createWrapper({
- pipeline: mockCopy.pipeline,
- });
- });
+ beforeEach(() => {
+ ({ pipeline } = JSON.parse(JSON.stringify(mockData)));
- it('should not render a pipeline graph', () => {
- expect(findPipelineGraph().exists()).toBe(false);
- });
+ pipeline.details.name = 'Pipeline';
+ pipeline.merge_request_event_type = undefined;
+ pipeline.ref.tag = false;
+ pipeline.ref.branch = false;
});
- describe('for each type of pipeline', () => {
- let pipeline;
-
- beforeEach(() => {
- ({ pipeline } = JSON.parse(JSON.stringify(mockData)));
-
- pipeline.details.name = 'Pipeline';
- pipeline.merge_request_event_type = undefined;
- pipeline.ref.tag = false;
- pipeline.ref.branch = false;
+ const factory = () => {
+ createWrapper({
+ pipeline,
+ sourceBranchLink: mockData.source_branch_link,
});
+ };
- const factory = () => {
- createWrapper({
- pipeline,
- sourceBranchLink: mockData.source_branch_link,
- });
- };
-
- describe('for a branch pipeline', () => {
- it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => {
- pipeline.ref.branch = true;
+ describe('for a branch pipeline', () => {
+ it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => {
+ pipeline.ref.branch = true;
- factory();
+ factory();
- const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on ${mockData.source_branch_link}`;
- const actual = trimText(findPipelineInfoContainer().text());
+ const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on ${mockData.source_branch_link}`;
+ const actual = trimText(findPipelineInfoContainer().text());
- expect(actual).toBe(expected);
- });
+ expect(actual).toBe(expected);
});
+ });
- describe('for a tag pipeline', () => {
- it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => {
- pipeline.ref.tag = true;
+ describe('for a tag pipeline', () => {
+ it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => {
+ pipeline.ref.tag = true;
- factory();
+ factory();
- const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`;
- const actual = trimText(findPipelineInfoContainer().text());
+ const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`;
+ const actual = trimText(findPipelineInfoContainer().text());
- expect(actual).toBe(expected);
- });
+ expect(actual).toBe(expected);
});
+ });
- describe('for a detached merge request pipeline', () => {
- it('renders a pipeline widget that reads "Detached merge request pipeline <ID> <status> for <SHA>"', () => {
- pipeline.details.name = 'Detached merge request pipeline';
- pipeline.merge_request_event_type = 'detached';
+ describe('for a detached merge request pipeline', () => {
+ it('renders a pipeline widget that reads "Detached merge request pipeline <ID> <status> for <SHA>"', () => {
+ pipeline.details.name = 'Detached merge request pipeline';
+ pipeline.merge_request_event_type = 'detached';
- factory();
+ factory();
- const expected = `Detached merge request pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`;
- const actual = trimText(findPipelineInfoContainer().text());
+ const expected = `Detached merge request pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`;
+ const actual = trimText(findPipelineInfoContainer().text());
- expect(actual).toBe(expected);
- });
+ expect(actual).toBe(expected);
});
});
});
diff --git a/spec/frontend/vue_mr_widget/components/review_app_link_spec.js b/spec/frontend/vue_mr_widget/components/review_app_link_spec.js
index 7b063653a93..7d47621c64a 100644
--- a/spec/frontend/vue_mr_widget/components/review_app_link_spec.js
+++ b/spec/frontend/vue_mr_widget/components/review_app_link_spec.js
@@ -30,7 +30,7 @@ describe('review app link', () => {
});
it('renders provided cssClass as class attribute', () => {
- expect(el.getAttribute('class')).toEqual(props.cssClass);
+ expect(el.getAttribute('class')).toContain(props.cssClass);
});
it('renders View app text', () => {
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js
index 67746b062b9..62fc3330444 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js
@@ -1,6 +1,5 @@
import { shallowMount } from '@vue/test-utils';
import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue';
-import Icon from '~/vue_shared/components/icon.vue';
describe('Commits header component', () => {
let wrapper;
@@ -23,7 +22,6 @@ describe('Commits header component', () => {
const findHeaderWrapper = () => wrapper.find('.js-mr-widget-commits-count');
const findCommitToggle = () => wrapper.find('.commit-edit-toggle');
- const findIcon = () => wrapper.find(Icon);
const findCommitsCountMessage = () => wrapper.find('.commits-count-message');
const findTargetBranchMessage = () => wrapper.find('.label-branch');
const findModifyButton = () => wrapper.find('.modify-message-button');
@@ -61,7 +59,7 @@ describe('Commits header component', () => {
wrapper.setData({ expanded: false });
return wrapper.vm.$nextTick().then(() => {
- expect(findIcon().props('name')).toBe('chevron-right');
+ expect(findCommitToggle().props('icon')).toBe('chevron-right');
});
});
@@ -119,7 +117,7 @@ describe('Commits header component', () => {
it('has a chevron-down icon', done => {
wrapper.vm.$nextTick(() => {
- expect(findIcon().props('name')).toBe('chevron-down');
+ expect(findCommitToggle().props('icon')).toBe('chevron-down');
done();
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
index c3a16a776a7..19f8a67d066 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
@@ -148,8 +148,8 @@ describe('MRWidgetConflicts', () => {
},
});
- expect(vm.contains('.js-resolve-conflicts-button')).toBe(false);
- expect(vm.contains('.js-merge-locally-button')).toBe(false);
+ expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false);
+ expect(vm.find('.js-merge-locally-button').exists()).toBe(false);
});
it('should not have resolve button when no conflict resolution path', () => {
@@ -161,7 +161,7 @@ describe('MRWidgetConflicts', () => {
},
});
- expect(vm.contains('.js-resolve-conflicts-button')).toBe(false);
+ expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false);
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js
index f591393d721..6778a8f4a1f 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js
@@ -125,7 +125,11 @@ describe('MRWidgetFailedToMerge', () => {
});
it('renders refresh button', () => {
- expect(vm.$el.querySelector('.js-refresh-button').textContent.trim()).toEqual('Refresh now');
+ expect(
+ vm.$el
+ .querySelector('[data-testid="merge-request-failed-refresh-button"]')
+ .textContent.trim(),
+ ).toEqual('Refresh now');
});
it('renders remaining time', () => {
diff --git a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js
index ffcf9b1477a..7fe6b44ecc7 100644
--- a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js
+++ b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js
@@ -1,4 +1,4 @@
-import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { invalidPlanWithName, plans, validPlanWithName } from './mock_data';
@@ -49,8 +49,8 @@ describe('MrWidgetTerraformConainer', () => {
});
it('diplays loading skeleton', () => {
- expect(wrapper.contains(GlSkeletonLoading)).toBe(true);
- expect(wrapper.contains(MrWidgetExpanableSection)).toBe(false);
+ expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
+ expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(false);
});
});
@@ -61,8 +61,8 @@ describe('MrWidgetTerraformConainer', () => {
});
it('displays terraform content', () => {
- expect(wrapper.contains(GlSkeletonLoading)).toBe(false);
- expect(wrapper.contains(MrWidgetExpanableSection)).toBe(true);
+ expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
+ expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(true);
expect(findPlans()).toEqual(Object.values(plans));
});
@@ -156,7 +156,7 @@ describe('MrWidgetTerraformConainer', () => {
});
it('stops loading', () => {
- expect(wrapper.contains(GlSkeletonLoading)).toBe(false);
+ expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
it('generates one broken plan', () => {
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js
index 6adf4975414..bc0d2501809 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import { GlIcon, GlLoadingIcon, GlDeprecatedButton } from '@gitlab/ui';
+import { GlIcon, GlLoadingIcon, GlButton } from '@gitlab/ui';
import DeploymentActionButton from '~/vue_merge_request_widget/components/deployment/deployment_action_button.vue';
import {
CREATED,
@@ -13,6 +13,7 @@ const baseProps = {
actionsConfiguration: actionButtonMocks[DEPLOYING],
actionInProgress: null,
computedDeploymentStatus: CREATED,
+ icon: 'play',
};
describe('Deployment action button', () => {
@@ -28,18 +29,18 @@ describe('Deployment action button', () => {
wrapper.destroy();
});
- describe('when passed only icon', () => {
+ describe('when passed only icon via props', () => {
beforeEach(() => {
factory({
propsData: baseProps,
- slots: { default: ['<gl-icon name="stop" />'] },
+ slots: {},
stubs: {
'gl-icon': GlIcon,
},
});
});
- it('renders slot correctly', () => {
+ it('renders prop icon correctly', () => {
expect(wrapper.find(GlIcon).exists()).toBe(true);
});
});
@@ -49,7 +50,7 @@ describe('Deployment action button', () => {
factory({
propsData: baseProps,
slots: {
- default: ['<gl-icon name="play" />', `<span>${actionButtonMocks[DEPLOYING]}</span>`],
+ default: [`<span>${actionButtonMocks[DEPLOYING]}</span>`],
},
stubs: {
'gl-icon': GlIcon,
@@ -57,7 +58,7 @@ describe('Deployment action button', () => {
});
});
- it('renders slot correctly', () => {
+ it('renders slot and icon prop correctly', () => {
expect(wrapper.find(GlIcon).exists()).toBe(true);
expect(wrapper.text()).toContain(actionButtonMocks[DEPLOYING]);
});
@@ -75,7 +76,7 @@ describe('Deployment action button', () => {
it('is disabled and shows the loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
- expect(wrapper.find(GlDeprecatedButton).props('disabled')).toBe(true);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(true);
});
});
@@ -90,7 +91,7 @@ describe('Deployment action button', () => {
});
it('is disabled and does not show the loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
- expect(wrapper.find(GlDeprecatedButton).props('disabled')).toBe(true);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(true);
});
});
@@ -106,7 +107,7 @@ describe('Deployment action button', () => {
});
it('is disabled and does not show the loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
- expect(wrapper.find(GlDeprecatedButton).props('disabled')).toBe(true);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(true);
});
});
@@ -118,7 +119,7 @@ describe('Deployment action button', () => {
});
it('is not disabled nor does it show the loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
- expect(wrapper.find(GlDeprecatedButton).props('disabled')).toBe(false);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(false);
});
});
});
diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js
index d64a7f88b6b..4688af30269 100644
--- a/spec/frontend/vue_mr_widget/mock_data.js
+++ b/spec/frontend/vue_mr_widget/mock_data.js
@@ -193,6 +193,7 @@ export default {
updated_at: '2017-04-07T15:28:44.800Z',
},
pipelineCoverageDelta: '15.25',
+ buildsWithCoverage: [{ name: 'karma', coverage: '40.2' }, { name: 'rspec', coverage: '80.4' }],
work_in_progress: false,
source_branch_exists: false,
mergeable_discussions_state: true,
diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
index 0bbe040d031..a2ade44b7c4 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -530,7 +530,7 @@ describe('mrWidgetOptions', () => {
vm.mr.state = 'readyToMerge';
vm.$nextTick(() => {
- const tooltip = vm.$el.querySelector('.fa-question-circle');
+ const tooltip = vm.$el.querySelector('[data-testid="question-o-icon"]');
expect(vm.$el.textContent).toContain('Deletes source branch');
expect(tooltip.getAttribute('data-original-title')).toBe(
diff --git a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js
index 128e0f39c41..631d4647b17 100644
--- a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js
+++ b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js
@@ -30,6 +30,7 @@ describe('getStateKey', () => {
expect(bound()).toEqual('notAllowedToMerge');
context.autoMergeEnabled = true;
+ context.hasMergeableDiscussionsState = true;
expect(bound()).toEqual('autoMergeEnabled');
@@ -44,6 +45,7 @@ describe('getStateKey', () => {
expect(bound()).toEqual('pipelineBlocked');
context.hasMergeableDiscussionsState = true;
+ context.autoMergeEnabled = false;
expect(bound()).toEqual('unresolvedDiscussions');
diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js
index 48326eda404..b691a366a0f 100644
--- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js
+++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js
@@ -69,6 +69,38 @@ describe('MergeRequestStore', () => {
});
});
+ describe('isPipelineBlocked', () => {
+ const pipelineWaitingForManualAction = {
+ details: {
+ status: {
+ group: 'manual',
+ },
+ },
+ };
+
+ it('should be `false` when the pipeline status is missing', () => {
+ store.setData({ ...mockData, pipeline: undefined });
+
+ expect(store.isPipelineBlocked).toBe(false);
+ });
+
+ it('should be `false` when the pipeline is waiting for manual action', () => {
+ store.setData({ ...mockData, pipeline: pipelineWaitingForManualAction });
+
+ expect(store.isPipelineBlocked).toBe(false);
+ });
+
+ it('should be `true` when the pipeline is waiting for manual action and the pipeline must succeed', () => {
+ store.setData({
+ ...mockData,
+ pipeline: pipelineWaitingForManualAction,
+ only_allow_merge_if_pipeline_succeeds: true,
+ });
+
+ expect(store.isPipelineBlocked).toBe(true);
+ });
+ });
+
describe('isNothingToMergeState', () => {
it('returns true when nothingToMerge', () => {
store.state = stateKey.nothingToMerge;
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
index e84eb7789d3..dfd114a2d1c 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
-<gl-new-dropdown-stub
+<gl-dropdown-stub
category="primary"
headertext=""
right=""
@@ -12,9 +12,9 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
<div
class="pb-2 mx-1"
>
- <gl-new-dropdown-header-stub>
+ <gl-dropdown-section-header-stub>
Clone with SSH
- </gl-new-dropdown-header-stub>
+ </gl-dropdown-section-header-stub>
<div
class="mx-3"
@@ -53,9 +53,9 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
</div>
</div>
- <gl-new-dropdown-header-stub>
+ <gl-dropdown-section-header-stub>
Clone with HTTP
- </gl-new-dropdown-header-stub>
+ </gl-dropdown-section-header-stub>
<div
class="mx-3"
@@ -94,5 +94,5 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
</div>
</div>
</div>
-</gl-new-dropdown-stub>
+</gl-dropdown-stub>
`;
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 cd4728baeaa..c2b97f1e7f9 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
@@ -4,7 +4,7 @@ exports[`Expand button on click when short text is provided renders button after
<span>
<button
aria-label="Click to expand text"
- class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button"
+ class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal"
style="display: none;"
type="button"
>
@@ -32,7 +32,7 @@ exports[`Expand button on click when short text is provided renders button after
<button
aria-label="Click to expand text"
- class="btn js-text-expander-append text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button"
+ class="btn js-text-expander-append text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal"
style=""
type="button"
>
@@ -56,7 +56,7 @@ exports[`Expand button when short text is provided renders button before text 1`
<span>
<button
aria-label="Click to expand text"
- class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button"
+ class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal"
type="button"
>
<!---->
@@ -83,7 +83,7 @@ exports[`Expand button when short text is provided renders button before text 1`
<button
aria-label="Click to expand text"
- class="btn js-text-expander-append text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button"
+ class="btn js-text-expander-append text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal"
style="display: none;"
type="button"
>
diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js
new file mode 100644
index 00000000000..4dde9d726d1
--- /dev/null
+++ b/spec/frontend/vue_shared/components/actions_button_spec.js
@@ -0,0 +1,203 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlLink } from '@gitlab/ui';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import ActionsButton from '~/vue_shared/components/actions_button.vue';
+
+const TEST_ACTION = {
+ key: 'action1',
+ text: 'Sample',
+ secondaryText: 'Lorem ipsum.',
+ tooltip: '',
+ href: '/sample',
+ attrs: { 'data-test': '123' },
+};
+const TEST_ACTION_2 = {
+ key: 'action2',
+ text: 'Sample 2',
+ secondaryText: 'Dolar sit amit.',
+ tooltip: 'Dolar sit amit.',
+ href: '#',
+ attrs: { 'data-test': '456' },
+};
+const TEST_TOOLTIP = 'Lorem ipsum dolar sit';
+
+describe('Actions button component', () => {
+ let wrapper;
+
+ function createComponent(props) {
+ wrapper = shallowMount(ActionsButton, {
+ propsData: { ...props },
+ directives: { GlTooltip: createMockDirective() },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const getTooltip = child => {
+ const directiveBinding = getBinding(child.element, 'gl-tooltip');
+
+ return directiveBinding.value;
+ };
+ const findLink = () => wrapper.find(GlLink);
+ const findLinkTooltip = () => getTooltip(findLink());
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownTooltip = () => getTooltip(findDropdown());
+ const parseDropdownItems = () =>
+ findDropdown()
+ .findAll('gl-dropdown-item-stub,gl-dropdown-divider-stub')
+ .wrappers.map(x => {
+ if (x.is('gl-dropdown-divider-stub')) {
+ return { type: 'divider' };
+ }
+
+ const { isCheckItem, isChecked, secondaryText } = x.props();
+
+ return {
+ type: 'item',
+ isCheckItem,
+ isChecked,
+ secondaryText,
+ text: x.text(),
+ };
+ });
+ const clickOn = (child, evt = new Event('click')) => child.vm.$emit('click', evt);
+ const clickLink = (...args) => clickOn(findLink(), ...args);
+ const clickDropdown = (...args) => clickOn(findDropdown(), ...args);
+
+ describe('with 1 action', () => {
+ beforeEach(() => {
+ createComponent({ actions: [TEST_ACTION] });
+ });
+
+ it('should not render dropdown', () => {
+ expect(findDropdown().exists()).toBe(false);
+ });
+
+ it('should render single button', () => {
+ const link = findLink();
+
+ expect(link.attributes()).toEqual({
+ class: expect.any(String),
+ href: TEST_ACTION.href,
+ ...TEST_ACTION.attrs,
+ });
+ expect(link.text()).toBe(TEST_ACTION.text);
+ });
+
+ it('should have tooltip', () => {
+ expect(findLinkTooltip()).toBe(TEST_ACTION.tooltip);
+ });
+
+ it('should have attrs', () => {
+ expect(findLink().attributes()).toMatchObject(TEST_ACTION.attrs);
+ });
+
+ it('can click', () => {
+ expect(clickLink).not.toThrow();
+ });
+ });
+
+ describe('with 1 action with tooltip', () => {
+ it('should have tooltip', () => {
+ createComponent({ actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }] });
+
+ expect(findLinkTooltip()).toBe(TEST_TOOLTIP);
+ });
+ });
+
+ describe('with 1 action with handle', () => {
+ it('can click and trigger handle', () => {
+ const handleClick = jest.fn();
+ createComponent({ actions: [{ ...TEST_ACTION, handle: handleClick }] });
+
+ const event = new Event('click');
+ clickLink(event);
+
+ expect(handleClick).toHaveBeenCalledWith(event);
+ });
+ });
+
+ describe('with multiple actions', () => {
+ let handleAction;
+
+ beforeEach(() => {
+ handleAction = jest.fn();
+
+ createComponent({ actions: [{ ...TEST_ACTION, handle: handleAction }, TEST_ACTION_2] });
+ });
+
+ it('should default to selecting first action', () => {
+ expect(findDropdown().attributes()).toMatchObject({
+ text: TEST_ACTION.text,
+ 'split-href': TEST_ACTION.href,
+ });
+ });
+
+ it('should handle first action click', () => {
+ const event = new Event('click');
+
+ clickDropdown(event);
+
+ expect(handleAction).toHaveBeenCalledWith(event);
+ });
+
+ it('should render dropdown items', () => {
+ expect(parseDropdownItems()).toEqual([
+ {
+ type: 'item',
+ isCheckItem: true,
+ isChecked: true,
+ secondaryText: TEST_ACTION.secondaryText,
+ text: TEST_ACTION.text,
+ },
+ { type: 'divider' },
+ {
+ type: 'item',
+ isCheckItem: true,
+ isChecked: false,
+ secondaryText: TEST_ACTION_2.secondaryText,
+ text: TEST_ACTION_2.text,
+ },
+ ]);
+ });
+
+ it('should select action 2 when clicked', () => {
+ expect(wrapper.emitted('select')).toBeUndefined();
+
+ const action2 = wrapper.find(`[data-testid="action_${TEST_ACTION_2.key}"]`);
+ action2.vm.$emit('click');
+
+ expect(wrapper.emitted('select')).toEqual([[TEST_ACTION_2.key]]);
+ });
+
+ it('should have tooltip value', () => {
+ expect(findDropdownTooltip()).toBe(TEST_ACTION.tooltip);
+ });
+ });
+
+ describe('with multiple actions and selectedKey', () => {
+ beforeEach(() => {
+ createComponent({ actions: [TEST_ACTION, TEST_ACTION_2], selectedKey: TEST_ACTION_2.key });
+ });
+
+ it('should show action 2 as selected', () => {
+ expect(parseDropdownItems()).toEqual([
+ expect.objectContaining({
+ type: 'item',
+ isChecked: false,
+ }),
+ { type: 'divider' },
+ expect.objectContaining({
+ type: 'item',
+ isChecked: true,
+ }),
+ ]);
+ });
+
+ it('should have tooltip value', () => {
+ expect(findDropdownTooltip()).toBe(TEST_ACTION_2.tooltip);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/alert_detail_table_spec.js b/spec/frontend/vue_shared/components/alert_detail_table_spec.js
new file mode 100644
index 00000000000..9c38ccad8a7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/alert_detail_table_spec.js
@@ -0,0 +1,74 @@
+import { mount } from '@vue/test-utils';
+import { GlTable, GlLoadingIcon } from '@gitlab/ui';
+import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+
+const mockAlert = {
+ iid: '1527542',
+ title: 'SyntaxError: Invalid or unexpected token',
+ severity: 'CRITICAL',
+ eventCount: 7,
+ createdAt: '2020-04-17T23:18:14.996Z',
+ startedAt: '2020-04-17T23:18:14.996Z',
+ endedAt: '2020-04-17T23:18:14.996Z',
+ status: 'TRIGGERED',
+ assignees: { nodes: [] },
+ notes: { nodes: [] },
+ todos: { nodes: [] },
+};
+
+describe('AlertDetails', () => {
+ let wrapper;
+
+ function mountComponent(propsData = {}) {
+ wrapper = mount(AlertDetailsTable, {
+ propsData: {
+ alert: mockAlert,
+ loading: false,
+ ...propsData,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findTableComponent = () => wrapper.find(GlTable);
+
+ describe('Alert details', () => {
+ describe('empty state', () => {
+ beforeEach(() => {
+ mountComponent({ alert: null });
+ });
+
+ it('shows an empty state when no alert is provided', () => {
+ expect(wrapper.text()).toContain('No alert data to display.');
+ });
+ });
+
+ describe('loading state', () => {
+ beforeEach(() => {
+ mountComponent({ loading: true });
+ });
+
+ it('displays a loading state when loading', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('with table data', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('renders a table', () => {
+ expect(findTableComponent().exists()).toBe(true);
+ });
+
+ it('renders a cell based on alert data', () => {
+ expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token');
+ });
+ });
+ });
+});
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 5cf42ecdc1d..22643a17b2b 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
@@ -36,6 +36,6 @@ describe('Blob Rich Viewer component', () => {
});
it('is using Markdown View Field', () => {
- expect(wrapper.contains(MarkdownFieldView)).toBe(true);
+ expect(wrapper.find(MarkdownFieldView).exists()).toBe(true);
});
});
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 03519a6f803..80918c5e771 100644
--- a/spec/frontend/vue_shared/components/changed_file_icon_spec.js
+++ b/spec/frontend/vue_shared/components/changed_file_icon_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
-import Icon from '~/vue_shared/components/icon.vue';
const changedFile = () => ({ changed: true });
const stagedFile = () => ({ changed: true, staged: true });
@@ -25,7 +25,7 @@ describe('Changed file icon', () => {
wrapper.destroy();
});
- const findIcon = () => wrapper.find(Icon);
+ const findIcon = () => wrapper.find(GlIcon);
const findIconName = () => findIcon().props('name');
const findIconClasses = () => findIcon().classes();
const findTooltipText = () => wrapper.attributes('title');
diff --git a/spec/frontend/vue_shared/components/clone_dropdown_spec.js b/spec/frontend/vue_shared/components/clone_dropdown_spec.js
index d9829874b93..5b8576ad761 100644
--- a/spec/frontend/vue_shared/components/clone_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/clone_dropdown_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlFormInputGroup, GlNewDropdownHeader } from '@gitlab/ui';
+import { GlFormInputGroup, GlDropdownSectionHeader } from '@gitlab/ui';
import CloneDropdown from '~/vue_shared/components/clone_dropdown.vue';
describe('Clone Dropdown Button', () => {
@@ -40,7 +40,7 @@ describe('Clone Dropdown Button', () => {
createComponent();
const group = wrapper.findAll(GlFormInputGroup).at(index);
expect(group.props('value')).toBe(value);
- expect(group.contains(GlFormInputGroup)).toBe(true);
+ expect(group.find(GlFormInputGroup).exists()).toBe(true);
});
it.each`
@@ -51,7 +51,7 @@ describe('Clone Dropdown Button', () => {
createComponent({ [name]: value });
expect(wrapper.find(GlFormInputGroup).props('value')).toBe(value);
- expect(wrapper.findAll(GlNewDropdownHeader).length).toBe(1);
+ expect(wrapper.findAll(GlDropdownSectionHeader).length).toBe(1);
});
});
@@ -63,12 +63,12 @@ describe('Clone Dropdown Button', () => {
`('allows null values for the props', ({ name, value }) => {
createComponent({ ...defaultPropsData, [name]: value });
- expect(wrapper.findAll(GlNewDropdownHeader).length).toBe(1);
+ expect(wrapper.findAll(GlDropdownSectionHeader).length).toBe(1);
});
it('correctly calculates httpLabel for HTTPS protocol', () => {
createComponent({ httpLink: httpsLink });
- expect(wrapper.find(GlNewDropdownHeader).text()).toContain('HTTPS');
+ expect(wrapper.find(GlDropdownSectionHeader).text()).toContain('HTTPS');
});
});
});
diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js
index 3510c9b699d..9b5c0941a0d 100644
--- a/spec/frontend/vue_shared/components/commit_spec.js
+++ b/spec/frontend/vue_shared/components/commit_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import CommitComponent from '~/vue_shared/components/commit.vue';
-import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
describe('Commit component', () => {
@@ -8,7 +8,7 @@ describe('Commit component', () => {
let wrapper;
const findIcon = name => {
- const icons = wrapper.findAll(Icon).filter(c => c.attributes('name') === name);
+ const icons = wrapper.findAll(GlIcon).filter(c => c.attributes('name') === name);
return icons.length ? icons.at(0) : icons;
};
@@ -46,7 +46,7 @@ describe('Commit component', () => {
expect(
wrapper
.find('.icon-container')
- .find(Icon)
+ .find(GlIcon)
.exists(),
).toBe(true);
});
diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js
index 7bccd6f1a64..5d92af64de0 100644
--- a/spec/frontend/vue_shared/components/confirm_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import { GlModal } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import ConfirmModal from '~/vue_shared/components/confirm_modal.vue';
@@ -21,9 +20,14 @@ describe('vue_shared/components/confirm_modal', () => {
selector: '.test-button',
};
- const actionSpies = {
- openModal: jest.fn(),
- closeModal: jest.fn(),
+ const popupMethods = {
+ hide: jest.fn(),
+ show: jest.fn(),
+ };
+
+ const GlModalStub = {
+ template: '<div><slot></slot></div>',
+ methods: popupMethods,
};
let wrapper;
@@ -34,8 +38,8 @@ describe('vue_shared/components/confirm_modal', () => {
...defaultProps,
...props,
},
- methods: {
- ...actionSpies,
+ stubs: {
+ GlModal: GlModalStub,
},
});
};
@@ -44,7 +48,7 @@ describe('vue_shared/components/confirm_modal', () => {
wrapper.destroy();
});
- const findModal = () => wrapper.find(GlModal);
+ const findModal = () => wrapper.find(GlModalStub);
const findForm = () => wrapper.find('form');
const findFormData = () =>
findForm()
@@ -103,7 +107,7 @@ describe('vue_shared/components/confirm_modal', () => {
});
it('does not close modal', () => {
- expect(actionSpies.closeModal).not.toHaveBeenCalled();
+ expect(popupMethods.hide).not.toHaveBeenCalled();
});
describe('when modal closed', () => {
@@ -112,7 +116,7 @@ describe('vue_shared/components/confirm_modal', () => {
});
it('closes modal', () => {
- expect(actionSpies.closeModal).toHaveBeenCalled();
+ expect(popupMethods.hide).toHaveBeenCalled();
});
});
});
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 223e22d650b..afd1f1a3123 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
@@ -234,7 +234,8 @@ describe('DateTimePicker', () => {
});
it('unchecks quick range when text is input is clicked', () => {
- const findActiveItems = () => findQuickRangeItems().filter(w => w.is('.active'));
+ const findActiveItems = () =>
+ findQuickRangeItems().filter(w => w.classes().includes('active'));
expect(findActiveItems().length).toBe(1);
@@ -332,13 +333,13 @@ describe('DateTimePicker', () => {
expect(items.length).toBe(Object.keys(otherTimeRanges).length);
expect(items.at(0).text()).toBe('1 minute');
- expect(items.at(0).is('.active')).toBe(false);
+ expect(items.at(0).classes()).not.toContain('active');
expect(items.at(1).text()).toBe('2 minutes');
- expect(items.at(1).is('.active')).toBe(true);
+ expect(items.at(1).classes()).toContain('active');
expect(items.at(2).text()).toBe('5 minutes');
- expect(items.at(2).is('.active')).toBe(false);
+ expect(items.at(2).classes()).not.toContain('active');
});
});
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 e0e982f4e11..e91e6577aaf 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
@@ -14,19 +14,13 @@ import {
const localVue = createLocalVue();
localVue.use(Vuex);
-function createRenamedComponent({
- props = {},
- methods = {},
- store = new Vuex.Store({}),
- deep = false,
-}) {
+function createRenamedComponent({ props = {}, store = new Vuex.Store({}), deep = false }) {
const mnt = deep ? mount : shallowMount;
return mnt(Renamed, {
propsData: { ...props },
localVue,
store,
- methods,
});
}
@@ -258,25 +252,17 @@ describe('Renamed Diff Viewer', () => {
'includes a link to the full file for alternate viewer type "$altType"',
({ altType, linkText }) => {
const file = { ...diffFile };
- const clickMock = jest.fn().mockImplementation(() => {});
file.alternate_viewer.name = altType;
wrapper = createRenamedComponent({
deep: true,
props: { diffFile: file },
- methods: {
- clickLink: clickMock,
- },
});
const link = wrapper.find('a');
expect(link.text()).toEqual(linkText);
expect(link.attributes('href')).toEqual(DIFF_FILE_VIEW_PATH);
-
- link.vm.$emit('click');
-
- expect(clickMock).toHaveBeenCalled();
},
);
});
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js
index ffdeb25439c..efa30bf6605 100644
--- a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js
@@ -32,12 +32,6 @@ describe('DropdownSearchInputComponent', () => {
expect(wrapper.find('.fa-search.dropdown-input-search').exists()).toBe(true);
});
- it('renders clear search icon element', () => {
- expect(wrapper.find('.fa-times.dropdown-input-clear.js-dropdown-input-clear').exists()).toBe(
- true,
- );
- });
-
it('displays custom placeholder text', () => {
expect(findInputEl().attributes('placeholder')).toBe(defaultProps.placeholderText);
});
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 f9e56774526..40026021777 100644
--- a/spec/frontend/vue_shared/components/file_finder/index_spec.js
+++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js
@@ -84,7 +84,7 @@ describe('File finder item spec', () => {
waitForPromises()
.then(() => {
- vm.$el.querySelector('.dropdown-input-clear').click();
+ vm.clearSearchInput();
})
.then(waitForPromises)
.then(() => {
@@ -94,13 +94,13 @@ describe('File finder item spec', () => {
.catch(done.fail);
});
- it('clear button focues search input', done => {
+ it('clear button focuses search input', done => {
jest.spyOn(vm.$refs.searchInput, 'focus').mockImplementation(() => {});
vm.searchText = 'index';
waitForPromises()
.then(() => {
- vm.$el.querySelector('.dropdown-input-clear').click();
+ vm.clearSearchInput();
})
.then(waitForPromises)
.then(() => {
@@ -319,8 +319,8 @@ describe('File finder item spec', () => {
.catch(done.fail);
});
- it('calls toggle on `command+p` key press', done => {
- Mousetrap.trigger('command+p');
+ it('calls toggle on `mod+p` key press', done => {
+ Mousetrap.trigger('mod+p');
vm.$nextTick()
.then(() => {
@@ -330,39 +330,28 @@ describe('File finder item spec', () => {
.catch(done.fail);
});
- it('calls toggle on `ctrl+p` key press', done => {
- Mousetrap.trigger('ctrl+p');
-
- vm.$nextTick()
- .then(() => {
- expect(vm.toggle).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('always allows `command+p` to trigger toggle', () => {
+ it('always allows `mod+p` to trigger toggle', () => {
expect(
- vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'),
- ).toBe(false);
- });
-
- it('always allows `ctrl+p` to trigger toggle', () => {
- expect(
- vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'),
+ Mousetrap.prototype.stopCallback(
+ null,
+ vm.$el.querySelector('.dropdown-input-field'),
+ 'mod+p',
+ ),
).toBe(false);
});
it('onlys handles `t` when focused in input-field', () => {
expect(
- vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'),
+ Mousetrap.prototype.stopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'),
).toBe(true);
});
it('stops callback in monaco editor', () => {
setFixtures('<div class="inputarea"></div>');
- expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true);
+ expect(
+ Mousetrap.prototype.stopCallback(null, document.querySelector('.inputarea'), 't'),
+ ).toBe(true);
});
});
});
diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js
index 1acd2e05464..d28c35d26bf 100644
--- a/spec/frontend/vue_shared/components/file_row_spec.js
+++ b/spec/frontend/vue_shared/components/file_row_spec.js
@@ -118,7 +118,7 @@ describe('File row component', () => {
level: 0,
});
- expect(wrapper.contains(FileHeader)).toBe(true);
+ expect(wrapper.find(FileHeader).exists()).toBe(true);
});
it('matches the current route against encoded file URL', () => {
@@ -139,4 +139,16 @@ describe('File row component', () => {
expect(wrapper.vm.hasUrlAtCurrentRoute()).toBe(true);
});
+
+ it('render with the correct file classes prop', () => {
+ createComponent({
+ file: {
+ ...file(),
+ },
+ level: 0,
+ fileClasses: 'font-weight-bold',
+ });
+
+ expect(wrapper.find('.file-row-name').classes()).toContain('font-weight-bold');
+ });
});
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 73dbecadd89..c79880d4766 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
@@ -1,19 +1,28 @@
import { shallowMount, mount } from '@vue/test-utils';
-import {
- GlFilteredSearch,
- GlButtonGroup,
- GlButton,
- GlNewDropdown as GlDropdown,
- GlNewDropdownItem as GlDropdownItem,
-} from '@gitlab/ui';
+import { GlFilteredSearch, GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants';
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
-import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data';
+import {
+ mockAvailableTokens,
+ mockSortOptions,
+ mockHistoryItems,
+ tokenValueAuthor,
+ tokenValueLabel,
+ tokenValueMilestone,
+} from './mock_data';
+
+jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({
+ uniqueTokens: jest.fn().mockImplementation(tokens => tokens),
+ stripQuotes: jest.requireActual(
+ '~/vue_shared/components/filtered_search_bar/filtered_search_utils',
+ ).stripQuotes,
+}));
const createComponent = ({
shallow = true,
@@ -52,10 +61,10 @@ describe('FilteredSearchBarRoot', () => {
expect(wrapper.vm.filterValue).toEqual([]);
expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending);
expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending);
- expect(wrapper.contains(GlButtonGroup)).toBe(true);
- expect(wrapper.contains(GlButton)).toBe(true);
- expect(wrapper.contains(GlDropdown)).toBe(true);
- expect(wrapper.contains(GlDropdownItem)).toBe(true);
+ expect(wrapper.find(GlButtonGroup).exists()).toBe(true);
+ expect(wrapper.find(GlButton).exists()).toBe(true);
+ expect(wrapper.find(GlDropdown).exists()).toBe(true);
+ expect(wrapper.find(GlDropdownItem).exists()).toBe(true);
});
it('does not initialize `selectedSortOption` and `selectedSortDirection` when `sortOptions` is not applied and hides the sort dropdown', () => {
@@ -63,23 +72,31 @@ describe('FilteredSearchBarRoot', () => {
expect(wrapperNoSort.vm.filterValue).toEqual([]);
expect(wrapperNoSort.vm.selectedSortOption).toBe(undefined);
- expect(wrapperNoSort.contains(GlButtonGroup)).toBe(false);
- expect(wrapperNoSort.contains(GlButton)).toBe(false);
- expect(wrapperNoSort.contains(GlDropdown)).toBe(false);
- expect(wrapperNoSort.contains(GlDropdownItem)).toBe(false);
+ expect(wrapperNoSort.find(GlButtonGroup).exists()).toBe(false);
+ expect(wrapperNoSort.find(GlButton).exists()).toBe(false);
+ expect(wrapperNoSort.find(GlDropdown).exists()).toBe(false);
+ expect(wrapperNoSort.find(GlDropdownItem).exists()).toBe(false);
});
});
describe('computed', () => {
describe('tokenSymbols', () => {
it('returns a map containing type and symbols from `tokens` prop', () => {
- expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@', label_name: '~' });
+ expect(wrapper.vm.tokenSymbols).toEqual({
+ author_username: '@',
+ label_name: '~',
+ milestone_title: '%',
+ });
});
});
describe('tokenTitles', () => {
it('returns a map containing type and title from `tokens` prop', () => {
- expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author', label_name: 'Label' });
+ expect(wrapper.vm.tokenTitles).toEqual({
+ author_username: 'Author',
+ label_name: 'Label',
+ milestone_title: 'Milestone',
+ });
});
});
@@ -131,6 +148,20 @@ describe('FilteredSearchBarRoot', () => {
expect(wrapper.vm.filteredRecentSearches[0]).toEqual({ foo: 'bar' });
});
+ it('returns array of recent searches sanitizing any duplicate token values', async () => {
+ wrapper.setData({
+ recentSearches: [
+ [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValueLabel],
+ [tokenValueAuthor, tokenValueMilestone],
+ ],
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.filteredRecentSearches).toHaveLength(2);
+ expect(uniqueTokens).toHaveBeenCalled();
+ });
+
it('returns undefined when recentSearchesStorageKey prop is not set on component', async () => {
wrapper.setProps({
recentSearchesStorageKey: '',
@@ -182,40 +213,12 @@ describe('FilteredSearchBarRoot', () => {
});
describe('removeQuotesEnclosure', () => {
- const mockFilters = [
- {
- type: 'author_username',
- value: {
- data: 'root',
- operator: '=',
- },
- },
- {
- type: 'label_name',
- value: {
- data: '"Documentation Update"',
- operator: '=',
- },
- },
- 'foo',
- ];
+ const mockFilters = [tokenValueAuthor, tokenValueLabel, 'foo'];
it('returns filter array with unescaped strings for values which have spaces', () => {
expect(wrapper.vm.removeQuotesEnclosure(mockFilters)).toEqual([
- {
- type: 'author_username',
- value: {
- data: 'root',
- operator: '=',
- },
- },
- {
- type: 'label_name',
- value: {
- data: 'Documentation Update',
- operator: '=',
- },
- },
+ tokenValueAuthor,
+ tokenValueLabel,
'foo',
]);
});
@@ -277,21 +280,26 @@ describe('FilteredSearchBarRoot', () => {
});
describe('handleFilterSubmit', () => {
- const mockFilters = [
- {
- type: 'author_username',
- value: {
- data: 'root',
- operator: '=',
- },
- },
- 'foo',
- ];
+ const mockFilters = [tokenValueAuthor, 'foo'];
+
+ beforeEach(async () => {
+ wrapper.setData({
+ filterValue: mockFilters,
+ });
+
+ await wrapper.vm.$nextTick();
+ });
+
+ it('calls `uniqueTokens` on `filterValue` prop to remove duplicates', () => {
+ wrapper.vm.handleFilterSubmit();
+
+ expect(uniqueTokens).toHaveBeenCalledWith(wrapper.vm.filterValue);
+ });
it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => {
jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch');
- wrapper.vm.handleFilterSubmit(mockFilters);
+ wrapper.vm.handleFilterSubmit();
return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters);
@@ -301,7 +309,7 @@ describe('FilteredSearchBarRoot', () => {
it('calls `recentSearchesService.save` with array of searches', () => {
jest.spyOn(wrapper.vm.recentSearchesService, 'save');
- wrapper.vm.handleFilterSubmit(mockFilters);
+ wrapper.vm.handleFilterSubmit();
return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]);
@@ -311,7 +319,7 @@ describe('FilteredSearchBarRoot', () => {
it('sets `recentSearches` data prop with array of searches', () => {
jest.spyOn(wrapper.vm.recentSearchesService, 'save');
- wrapper.vm.handleFilterSubmit(mockFilters);
+ wrapper.vm.handleFilterSubmit();
return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearches).toEqual([mockFilters]);
@@ -329,7 +337,7 @@ describe('FilteredSearchBarRoot', () => {
it('emits component event `onFilter` with provided filters param', () => {
jest.spyOn(wrapper.vm, 'removeQuotesEnclosure');
- wrapper.vm.handleFilterSubmit(mockFilters);
+ wrapper.vm.handleFilterSubmit();
expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]);
expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockFilters);
@@ -366,7 +374,9 @@ describe('FilteredSearchBarRoot', () => {
'.gl-search-box-by-click-menu .gl-search-box-by-click-history-item',
);
- expect(searchHistoryItemsEl.at(0).text()).toBe('Author := @tobyLabel := ~Bug"duo"');
+ expect(searchHistoryItemsEl.at(0).text()).toBe(
+ 'Author := @rootLabel := ~bugMilestone := %v1.0"duo"',
+ );
wrapperFullMount.destroy();
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
index a857f84adf1..4869e75a2f3 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
@@ -1,4 +1,18 @@
-import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import {
+ stripQuotes,
+ uniqueTokens,
+ prepareTokens,
+ processFilters,
+ filterToQueryObject,
+ urlQueryToFilter,
+} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+
+import {
+ tokenValueAuthor,
+ tokenValueLabel,
+ tokenValueMilestone,
+ tokenValuePlain,
+} from './mock_data';
describe('Filtered Search Utils', () => {
describe('stripQuotes', () => {
@@ -9,11 +23,196 @@ describe('Filtered Search Utils', () => {
${'FooBar'} | ${'FooBar'}
${"Foo'Bar"} | ${"Foo'Bar"}
${'Foo"Bar'} | ${'Foo"Bar'}
+ ${'Foo Bar'} | ${'Foo Bar'}
`(
'returns string $outputValue when called with string $inputValue',
({ inputValue, outputValue }) => {
- expect(filteredSearchUtils.stripQuotes(inputValue)).toBe(outputValue);
+ expect(stripQuotes(inputValue)).toBe(outputValue);
},
);
});
+
+ describe('uniqueTokens', () => {
+ it('returns tokens array with duplicates removed', () => {
+ expect(
+ uniqueTokens([
+ tokenValueAuthor,
+ tokenValueLabel,
+ tokenValueMilestone,
+ tokenValueLabel,
+ tokenValuePlain,
+ ]),
+ ).toHaveLength(4); // Removes 2nd instance of tokenValueLabel
+ });
+
+ it('returns tokens array as it is if it does not have duplicates', () => {
+ expect(
+ uniqueTokens([tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValuePlain]),
+ ).toHaveLength(4);
+ });
+ });
+});
+
+describe('prepareTokens', () => {
+ describe('with empty data', () => {
+ it('returns an empty array', () => {
+ expect(prepareTokens()).toEqual([]);
+ expect(prepareTokens({})).toEqual([]);
+ expect(prepareTokens({ milestone: null, author: null, assignees: [], labels: [] })).toEqual(
+ [],
+ );
+ });
+ });
+
+ it.each([
+ [
+ 'milestone',
+ { value: 'v1.0', operator: '=' },
+ [{ type: 'milestone', value: { data: 'v1.0', operator: '=' } }],
+ ],
+ [
+ 'author',
+ { value: 'mr.popo', operator: '!=' },
+ [{ type: 'author', value: { data: 'mr.popo', operator: '!=' } }],
+ ],
+ [
+ 'labels',
+ [{ value: 'z-fighters', operator: '=' }],
+ [{ type: 'labels', value: { data: 'z-fighters', operator: '=' } }],
+ ],
+ [
+ 'assignees',
+ [{ value: 'krillin', operator: '=' }, { value: 'piccolo', operator: '!=' }],
+ [
+ { type: 'assignees', value: { data: 'krillin', operator: '=' } },
+ { type: 'assignees', value: { data: 'piccolo', operator: '!=' } },
+ ],
+ ],
+ [
+ 'foo',
+ [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }],
+ [
+ { type: 'foo', value: { data: 'bar', operator: '!=' } },
+ { type: 'foo', value: { data: 'baz', operator: '!=' } },
+ ],
+ ],
+ ])('gathers %s=%j into result=%j', (token, value, result) => {
+ const res = prepareTokens({ [token]: value });
+ expect(res).toEqual(result);
+ });
+});
+
+describe('processFilters', () => {
+ it('processes multiple filter values', () => {
+ const result = processFilters([
+ { type: 'foo', value: { data: 'foo', operator: '=' } },
+ { type: 'bar', value: { data: 'bar1', operator: '=' } },
+ { type: 'bar', value: { data: 'bar2', operator: '!=' } },
+ ]);
+
+ expect(result).toStrictEqual({
+ foo: [{ value: 'foo', operator: '=' }],
+ bar: [{ value: 'bar1', operator: '=' }, { value: 'bar2', operator: '!=' }],
+ });
+ });
+
+ it('does not remove wrapping double quotes from the data', () => {
+ const result = processFilters([
+ { type: 'foo', value: { data: '"value with spaces"', operator: '=' } },
+ ]);
+
+ expect(result).toStrictEqual({
+ foo: [{ value: '"value with spaces"', operator: '=' }],
+ });
+ });
+});
+
+describe('filterToQueryObject', () => {
+ describe('with empty data', () => {
+ it('returns an empty object', () => {
+ expect(filterToQueryObject()).toEqual({});
+ expect(filterToQueryObject({})).toEqual({});
+ expect(filterToQueryObject({ author_username: null, label_name: [] })).toEqual({
+ author_username: null,
+ label_name: null,
+ 'not[author_username]': null,
+ 'not[label_name]': null,
+ });
+ });
+ });
+
+ it.each([
+ [
+ 'author_username',
+ { value: 'v1.0', operator: '=' },
+ { author_username: 'v1.0', 'not[author_username]': null },
+ ],
+ [
+ 'author_username',
+ { value: 'v1.0', operator: '!=' },
+ { author_username: null, 'not[author_username]': 'v1.0' },
+ ],
+ [
+ 'label_name',
+ [{ value: 'z-fighters', operator: '=' }],
+ { label_name: ['z-fighters'], 'not[label_name]': null },
+ ],
+ [
+ 'label_name',
+ [{ value: 'z-fighters', operator: '!=' }],
+ { label_name: null, 'not[label_name]': ['z-fighters'] },
+ ],
+ [
+ 'foo',
+ [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }],
+ { foo: ['bar', 'baz'], 'not[foo]': null },
+ ],
+ [
+ 'foo',
+ [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }],
+ { foo: null, 'not[foo]': ['bar', 'baz'] },
+ ],
+ [
+ 'foo',
+ [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '=' }],
+ { foo: ['baz'], 'not[foo]': ['bar'] },
+ ],
+ ])('gathers filter values %s=%j into query object=%j', (token, value, result) => {
+ const res = filterToQueryObject({ [token]: value });
+ expect(res).toEqual(result);
+ });
+});
+
+describe('urlQueryToFilter', () => {
+ describe('with empty data', () => {
+ it('returns an empty object', () => {
+ expect(urlQueryToFilter()).toEqual({});
+ expect(urlQueryToFilter('')).toEqual({});
+ expect(urlQueryToFilter('author_username=&milestone_title=&')).toEqual({});
+ });
+ });
+
+ it.each([
+ ['author_username=v1.0', { author_username: { value: 'v1.0', operator: '=' } }],
+ ['not[author_username]=v1.0', { author_username: { value: 'v1.0', operator: '!=' } }],
+ ['foo=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }],
+ ['foo=bar&foo[]=baz', { foo: [{ value: 'baz', operator: '=' }] }],
+ ['not[foo]=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }],
+ [
+ 'foo[]=bar&foo[]=baz&not[foo]=',
+ { foo: [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }] },
+ ],
+ [
+ 'foo[]=&not[foo][]=bar&not[foo][]=baz',
+ { foo: [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }] },
+ ],
+ [
+ 'foo[]=baz&not[foo][]=bar',
+ { foo: [{ value: 'baz', operator: '=' }, { value: 'bar', operator: '!=' }] },
+ ],
+ ['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }],
+ ])('gathers filter values %s into query object=%j', (query, result) => {
+ const res = urlQueryToFilter(query);
+ expect(res).toEqual(result);
+ });
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
index dcccb1f49b6..e0a3208cac9 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
@@ -1,6 +1,7 @@
import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
@@ -33,6 +34,8 @@ export const mockAuthor3 = {
export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3];
+export const mockBranches = [{ name: 'Master' }, { name: 'v1.x' }, { name: 'my-Branch' }];
+
export const mockRegularMilestone = {
id: 1,
name: '4.0',
@@ -55,6 +58,16 @@ export const mockMilestones = [
mockEscapedMilestone,
];
+export const mockBranchToken = {
+ type: 'source_branch',
+ icon: 'branch',
+ title: 'Source Branch',
+ unique: true,
+ token: BranchToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchBranches: Api.branches.bind(Api),
+};
+
export const mockAuthorToken = {
type: 'author_username',
icon: 'user',
@@ -89,36 +102,40 @@ export const mockMilestoneToken = {
fetchMilestones: () => Promise.resolve({ data: mockMilestones }),
};
-export const mockAvailableTokens = [mockAuthorToken, mockLabelToken];
+export const mockAvailableTokens = [mockAuthorToken, mockLabelToken, mockMilestoneToken];
+
+export const tokenValueAuthor = {
+ type: 'author_username',
+ value: {
+ data: 'root',
+ operator: '=',
+ },
+};
+
+export const tokenValueLabel = {
+ type: 'label_name',
+ value: {
+ operator: '=',
+ data: 'bug',
+ },
+};
+
+export const tokenValueMilestone = {
+ type: 'milestone_title',
+ value: {
+ operator: '=',
+ data: 'v1.0',
+ },
+};
+
+export const tokenValuePlain = {
+ type: 'filtered-search-term',
+ value: { data: 'foo' },
+};
export const mockHistoryItems = [
- [
- {
- type: 'author_username',
- value: {
- data: 'toby',
- operator: '=',
- },
- },
- {
- type: 'label_name',
- value: {
- data: 'Bug',
- operator: '=',
- },
- },
- 'duo',
- ],
- [
- {
- type: 'author_username',
- value: {
- data: 'root',
- operator: '=',
- },
- },
- 'si',
- ],
+ [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'],
+ [tokenValueAuthor, 'si'],
];
export const mockSortOptions = [
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
index 160febf9d06..72840ce381f 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -1,18 +1,42 @@
import { mount } from '@vue/test-utils';
-import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchTokenSegment,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+} from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
+import {
+ DEFAULT_LABEL_NONE,
+ DEFAULT_LABEL_ANY,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import { mockAuthorToken, mockAuthors } from '../mock_data';
jest.mock('~/flash');
-
-const createComponent = ({ config = mockAuthorToken, value = { data: '' }, active = false } = {}) =>
- mount(AuthorToken, {
+const defaultStubs = {
+ Portal: true,
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+};
+
+function createComponent(options = {}) {
+ const {
+ config = mockAuthorToken,
+ value = { data: '' },
+ active = false,
+ stubs = defaultStubs,
+ } = options;
+ return mount(AuthorToken, {
propsData: {
config,
value,
@@ -22,18 +46,9 @@ const createComponent = ({ config = mockAuthorToken, value = { data: '' }, activ
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
},
- stubs: {
- Portal: {
- template: '<div><slot></slot></div>',
- },
- GlFilteredSearchSuggestionList: {
- template: '<div></div>',
- methods: {
- getValue: () => '=',
- },
- },
- },
+ stubs,
});
+}
describe('AuthorToken', () => {
let mock;
@@ -141,5 +156,57 @@ describe('AuthorToken', () => {
expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator"
});
});
+
+ it('renders provided defaultAuthors as suggestions', async () => {
+ const defaultAuthors = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockAuthorToken, defaultAuthors },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(defaultAuthors.length);
+ defaultAuthors.forEach((label, index) => {
+ expect(suggestions.at(index).text()).toBe(label.text);
+ });
+ });
+
+ it('does not render divider when no defaultAuthors', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockAuthorToken, defaultAuthors: [] },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false);
+ expect(wrapper.contains(GlDropdownDivider)).toBe(false);
+ });
+
+ it('renders `DEFAULT_LABEL_ANY` as default suggestions', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockAuthorToken },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(1);
+ expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text);
+ });
});
});
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
new file mode 100644
index 00000000000..12b7fd58670
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
@@ -0,0 +1,207 @@
+import { mount } from '@vue/test-utils';
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchTokenSegment,
+ GlDropdownDivider,
+} from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import {
+ DEFAULT_LABEL_NONE,
+ DEFAULT_LABEL_ANY,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
+
+import { mockBranches, mockBranchToken } from '../mock_data';
+
+jest.mock('~/flash');
+const defaultStubs = {
+ Portal: true,
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+};
+
+function createComponent(options = {}) {
+ const {
+ config = mockBranchToken,
+ value = { data: '' },
+ active = false,
+ stubs = defaultStubs,
+ } = options;
+ return mount(BranchToken, {
+ propsData: {
+ config,
+ value,
+ active,
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: function fakeAlignSuggestions() {},
+ },
+ stubs,
+ });
+}
+
+describe('BranchToken', () => {
+ let mock;
+ let wrapper;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({ value: { data: mockBranches[0].name } });
+
+ wrapper.setData({
+ branches: mockBranches,
+ });
+
+ await wrapper.vm.$nextTick();
+ });
+
+ describe('currentValue', () => {
+ it('returns lowercase string for `value.data`', () => {
+ expect(wrapper.vm.currentValue).toBe('master');
+ });
+ });
+
+ describe('activeBranch', () => {
+ it('returns object for currently present `value.data`', () => {
+ expect(wrapper.vm.activeBranch).toEqual(mockBranches[0]);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ describe('fetchBranchBySearchTerm', () => {
+ it('calls `config.fetchBranches` with provided searchTerm param', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchBranches');
+
+ wrapper.vm.fetchBranchBySearchTerm('foo');
+
+ expect(wrapper.vm.config.fetchBranches).toHaveBeenCalledWith('foo');
+ });
+
+ it('sets response to `branches` when request is succesful', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchBranches').mockResolvedValue({ data: mockBranches });
+
+ wrapper.vm.fetchBranchBySearchTerm('foo');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.branches).toEqual(mockBranches);
+ });
+ });
+
+ it('calls `createFlash` with flash error message when request fails', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({});
+
+ wrapper.vm.fetchBranchBySearchTerm('foo');
+
+ return waitForPromises().then(() => {
+ expect(createFlash).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.fetchBranchBySearchTerm('foo');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.loading).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('template', () => {
+ const defaultBranches = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+ async function showSuggestions() {
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+ }
+
+ beforeEach(async () => {
+ wrapper = createComponent({ value: { data: mockBranches[0].name } });
+
+ wrapper.setData({
+ branches: mockBranches,
+ });
+
+ await wrapper.vm.$nextTick();
+ });
+
+ it('renders gl-filtered-search-token component', () => {
+ expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
+ });
+
+ it('renders token item when value is selected', () => {
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+
+ expect(tokenSegments).toHaveLength(3);
+ expect(tokenSegments.at(2).text()).toBe(mockBranches[0].name);
+ });
+
+ it('renders provided defaultBranches as suggestions', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockBranchToken, defaultBranches },
+ stubs: { Portal: true },
+ });
+ await showSuggestions();
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(defaultBranches.length);
+ defaultBranches.forEach((branch, index) => {
+ expect(suggestions.at(index).text()).toBe(branch.text);
+ });
+ });
+
+ it('does not render divider when no defaultBranches', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockBranchToken, defaultBranches: [] },
+ stubs: { Portal: true },
+ });
+ await showSuggestions();
+
+ expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false);
+ expect(wrapper.contains(GlDropdownDivider)).toBe(false);
+ });
+
+ it('renders no suggestions as default', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockBranchToken },
+ stubs: { Portal: true },
+ });
+ await showSuggestions();
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(0);
+ });
+ });
+});
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 0e60ee99327..3feb05bab35 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
@@ -1,5 +1,10 @@
import { mount } from '@vue/test-utils';
-import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchTokenSegment,
+ GlDropdownDivider,
+} from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import {
@@ -9,14 +14,34 @@ import {
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
+import {
+ DEFAULT_LABELS,
+ DEFAULT_LABEL_NONE,
+ DEFAULT_LABEL_ANY,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import { mockLabelToken } from '../mock_data';
jest.mock('~/flash');
-
-const createComponent = ({ config = mockLabelToken, value = { data: '' }, active = false } = {}) =>
- mount(LabelToken, {
+const defaultStubs = {
+ Portal: true,
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+};
+
+function createComponent(options = {}) {
+ const {
+ config = mockLabelToken,
+ value = { data: '' },
+ active = false,
+ stubs = defaultStubs,
+ } = options;
+ return mount(LabelToken, {
propsData: {
config,
value,
@@ -26,18 +51,9 @@ const createComponent = ({ config = mockLabelToken, value = { data: '' }, active
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
},
- stubs: {
- Portal: {
- template: '<div><slot></slot></div>',
- },
- GlFilteredSearchSuggestionList: {
- template: '<div></div>',
- methods: {
- getValue: () => '=',
- },
- },
- },
+ stubs,
});
+}
describe('LabelToken', () => {
let mock;
@@ -45,7 +61,6 @@ describe('LabelToken', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
- wrapper = createComponent();
});
afterEach(() => {
@@ -98,6 +113,10 @@ describe('LabelToken', () => {
});
describe('methods', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
describe('fetchLabelBySearchTerm', () => {
it('calls `config.fetchLabels` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels');
@@ -140,6 +159,8 @@ describe('LabelToken', () => {
});
describe('template', () => {
+ const defaultLabels = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+
beforeEach(async () => {
wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } });
@@ -166,5 +187,58 @@ describe('LabelToken', () => {
.attributes('style'),
).toBe('background-color: rgb(186, 218, 85); color: rgb(255, 255, 255);');
});
+
+ it('renders provided defaultLabels as suggestions', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockLabelToken, defaultLabels },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(defaultLabels.length);
+ defaultLabels.forEach((label, index) => {
+ expect(suggestions.at(index).text()).toBe(label.text);
+ });
+ });
+
+ it('does not render divider when no defaultLabels', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockLabelToken, defaultLabels: [] },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false);
+ expect(wrapper.contains(GlDropdownDivider)).toBe(false);
+ });
+
+ it('renders `DEFAULT_LABELS` as default suggestions', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockLabelToken },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(DEFAULT_LABELS.length);
+ DEFAULT_LABELS.forEach((label, index) => {
+ expect(suggestions.at(index).text()).toBe(label.text);
+ });
+ });
});
});
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 de893bf44c8..0ec814e3f15 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
@@ -1,10 +1,16 @@
import { mount } from '@vue/test-utils';
-import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchTokenSegment,
+ GlDropdownDivider,
+} from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
+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 {
@@ -16,12 +22,24 @@ import {
jest.mock('~/flash');
-const createComponent = ({
- config = mockMilestoneToken,
- value = { data: '' },
- active = false,
-} = {}) =>
- mount(MilestoneToken, {
+const defaultStubs = {
+ Portal: true,
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+};
+
+function createComponent(options = {}) {
+ const {
+ config = mockMilestoneToken,
+ value = { data: '' },
+ active = false,
+ stubs = defaultStubs,
+ } = options;
+ return mount(MilestoneToken, {
propsData: {
config,
value,
@@ -31,18 +49,9 @@ const createComponent = ({
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
},
- stubs: {
- Portal: {
- template: '<div><slot></slot></div>',
- },
- GlFilteredSearchSuggestionList: {
- template: '<div></div>',
- methods: {
- getValue: () => '=',
- },
- },
- },
+ stubs,
});
+}
describe('MilestoneToken', () => {
let mock;
@@ -128,6 +137,8 @@ describe('MilestoneToken', () => {
});
describe('template', () => {
+ const defaultMilestones = [{ text: 'foo', value: 'foo' }, { text: 'bar', value: 'baz' }];
+
beforeEach(async () => {
wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` } });
@@ -146,7 +157,60 @@ describe('MilestoneToken', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3); // Milestone, =, '%"4.0"'
- expect(tokenSegments.at(2).text()).toBe(`%"${mockRegularMilestone.title}"`); // "4.0 RC1"
+ expect(tokenSegments.at(2).text()).toBe(`%${mockRegularMilestone.title}`); // "4.0 RC1"
+ });
+
+ it('renders provided defaultMilestones as suggestions', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockMilestoneToken, defaultMilestones },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(defaultMilestones.length);
+ defaultMilestones.forEach((milestone, index) => {
+ expect(suggestions.at(index).text()).toBe(milestone.text);
+ });
+ });
+
+ it('does not render divider when no defaultMilestones', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockMilestoneToken, defaultMilestones: [] },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false);
+ expect(wrapper.contains(GlDropdownDivider)).toBe(false);
+ });
+
+ it('renders `DEFAULT_MILESTONES` as default suggestions', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockMilestoneToken },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(DEFAULT_MILESTONES.length);
+ DEFAULT_MILESTONES.forEach((milestone, index) => {
+ expect(suggestions.at(index).text()).toBe(milestone.text);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js
deleted file mode 100644
index 87cafa0bb8c..00000000000
--- a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js
+++ /dev/null
@@ -1,190 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import component from '~/vue_shared/components/filtered_search_dropdown.vue';
-
-describe('Filtered search dropdown', () => {
- const Component = Vue.extend(component);
- let vm;
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('with an empty array of items', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- items: [],
- filterKey: '',
- });
- });
-
- it('renders empty list', () => {
- expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0);
- });
-
- it('renders filter input', () => {
- expect(vm.$el.querySelector('.js-filtered-dropdown-input')).not.toBeNull();
- });
- });
-
- describe('when visible numbers is less than the items length', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }],
- visibleItems: 2,
- filterKey: 'title',
- });
- });
-
- it('it renders only the maximum number provided', () => {
- expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2);
- });
- });
-
- describe('when visible number is bigger than the items length', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }],
- filterKey: 'title',
- });
- });
-
- it('it renders the full list of items the maximum number provided', () => {
- expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(3);
- });
- });
-
- describe('while filtering', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- items: [
- { title: 'One' },
- { title: 'Two/three' },
- { title: 'Three four' },
- { title: 'Five' },
- ],
- filterKey: 'title',
- });
- });
-
- it('updates the results to match the typed value', done => {
- vm.$el.querySelector('.js-filtered-dropdown-input').value = 'three';
- vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
- vm.$nextTick(() => {
- expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2);
- done();
- });
- });
-
- describe('when no value matches the typed one', () => {
- it('does not render any result', done => {
- vm.$el.querySelector('.js-filtered-dropdown-input').value = 'six';
- vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0);
- done();
- });
- });
- });
- });
-
- describe('with create mode enabled', () => {
- describe('when there are no matches', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- items: [
- { title: 'One' },
- { title: 'Two/three' },
- { title: 'Three four' },
- { title: 'Five' },
- ],
- filterKey: 'title',
- showCreateMode: true,
- });
-
- vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven';
- vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
- });
-
- it('renders a create button', done => {
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.js-dropdown-create-button')).not.toBeNull();
- done();
- });
- });
-
- it('renders computed button text', done => {
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.js-dropdown-create-button').textContent.trim()).toEqual(
- 'Create eleven',
- );
- done();
- });
- });
-
- describe('on click create button', () => {
- it('emits createItem event with the filter', done => {
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
- vm.$nextTick(() => {
- vm.$el.querySelector('.js-dropdown-create-button').click();
-
- expect(vm.$emit).toHaveBeenCalledWith('createItem', 'eleven');
- done();
- });
- });
- });
- });
-
- describe('when there are matches', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- items: [
- { title: 'One' },
- { title: 'Two/three' },
- { title: 'Three four' },
- { title: 'Five' },
- ],
- filterKey: 'title',
- showCreateMode: true,
- });
-
- vm.$el.querySelector('.js-filtered-dropdown-input').value = 'one';
- vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
- });
-
- it('does not render a create button', done => {
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull();
- done();
- });
- });
- });
- });
-
- describe('with create mode disabled', () => {
- describe('when there are no matches', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- items: [
- { title: 'One' },
- { title: 'Two/three' },
- { title: 'Three four' },
- { title: 'Five' },
- ],
- filterKey: 'title',
- });
-
- vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven';
- vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
- });
-
- it('does not render a create button', done => {
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull();
- done();
- });
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/icon_spec.js b/spec/frontend/vue_shared/components/icon_spec.js
deleted file mode 100644
index 16728e1705a..00000000000
--- a/spec/frontend/vue_shared/components/icon_spec.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import Vue from 'vue';
-import { mount } from '@vue/test-utils';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import iconsPath from '@gitlab/svgs/dist/icons.svg';
-import Icon from '~/vue_shared/components/icon.vue';
-
-jest.mock('@gitlab/svgs/dist/icons.svg', () => 'testing');
-
-describe('Sprite Icon Component', () => {
- describe('Initialization', () => {
- let icon;
-
- beforeEach(() => {
- const IconComponent = Vue.extend(Icon);
-
- icon = mountComponent(IconComponent, {
- name: 'commit',
- size: 32,
- });
- });
-
- afterEach(() => {
- icon.$destroy();
- });
-
- it('should return a defined Vue component', () => {
- expect(icon).toBeDefined();
- });
-
- it('should have <svg> as a child element', () => {
- expect(icon.$el.tagName).toBe('svg');
- });
-
- it('should have <use> as a child element with the correct href', () => {
- expect(icon.$el.firstChild.tagName).toBe('use');
- expect(icon.$el.firstChild.getAttribute('xlink:href')).toBe(`${iconsPath}#commit`);
- });
-
- it('should properly compute iconSizeClass', () => {
- expect(icon.iconSizeClass).toBe('s32');
- });
-
- it('forbids invalid size prop', () => {
- expect(icon.$options.props.size.validator(NaN)).toBeFalsy();
- expect(icon.$options.props.size.validator(0)).toBeFalsy();
- expect(icon.$options.props.size.validator(9001)).toBeFalsy();
- });
-
- it('should properly render img css', () => {
- const { classList } = icon.$el;
- const containsSizeClass = classList.contains('s32');
-
- expect(containsSizeClass).toBe(true);
- });
-
- it('`name` validator should return false for non existing icons', () => {
- jest.spyOn(console, 'warn').mockImplementation();
-
- expect(Icon.props.name.validator('non_existing_icon_sprite')).toBe(false);
- });
-
- it('`name` validator should return true for existing icons', () => {
- expect(Icon.props.name.validator('commit')).toBe(true);
- });
- });
-
- it('should call registered listeners when they are triggered', () => {
- const clickHandler = jest.fn();
- const wrapper = mount(Icon, {
- propsData: { name: 'commit' },
- listeners: { click: clickHandler },
- });
-
- wrapper.find('svg').trigger('click');
-
- expect(clickHandler).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
index b72f78c4f60..c87d19df1f7 100644
--- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
+++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
@@ -2,8 +2,8 @@ import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import { mockMilestone } from 'jest/boards/mock_data';
+import { GlIcon } from '@gitlab/ui';
import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
-import Icon from '~/vue_shared/components/icon.vue';
const createComponent = (milestone = mockMilestone) => {
const Component = Vue.extend(IssueMilestone);
@@ -135,7 +135,7 @@ describe('IssueMilestoneComponent', () => {
});
it('renders milestone icon', () => {
- expect(wrapper.find(Icon).props('name')).toBe('clock');
+ expect(wrapper.find(GlIcon).props('name')).toBe('clock');
});
it('renders milestone title', () => {
diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
index fb9487d0bf8..2319bf61482 100644
--- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
+++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
@@ -35,6 +35,9 @@ describe('RelatedIssuableItem', () => {
weight: '<div class="js-weight-slot"></div>',
};
+ const findRemoveButton = () => wrapper.find({ ref: 'removeButton' });
+ const findLockIcon = () => wrapper.find({ ref: 'lockIcon' });
+
beforeEach(() => {
mountComponent({ props, slots });
});
@@ -121,10 +124,10 @@ describe('RelatedIssuableItem', () => {
});
it('renders milestone icon and name', () => {
- const milestoneIcon = tokenMetadata().find('.item-milestone svg use');
+ const milestoneIcon = tokenMetadata().find('.item-milestone svg');
const milestoneTitle = tokenMetadata().find('.item-milestone .milestone-title');
- expect(milestoneIcon.attributes('href')).toContain('clock');
+ expect(milestoneIcon.attributes('data-testid')).toBe('clock-icon');
expect(milestoneTitle.text()).toContain('Milestone title');
});
@@ -143,25 +146,27 @@ describe('RelatedIssuableItem', () => {
});
describe('remove button', () => {
- const removeButton = () => wrapper.find({ ref: 'removeButton' });
-
beforeEach(() => {
wrapper.setProps({ canRemove: true });
});
it('renders if canRemove', () => {
- expect(removeButton().exists()).toBe(true);
+ expect(findRemoveButton().exists()).toBe(true);
+ });
+
+ it('does not render the lock icon', () => {
+ expect(findLockIcon().exists()).toBe(false);
});
it('renders disabled button when removeDisabled', async () => {
wrapper.setData({ removeDisabled: true });
await wrapper.vm.$nextTick();
- expect(removeButton().attributes('disabled')).toEqual('disabled');
+ expect(findRemoveButton().attributes('disabled')).toEqual('disabled');
});
it('triggers onRemoveRequest when clicked', async () => {
- removeButton().trigger('click');
+ findRemoveButton().trigger('click');
await wrapper.vm.$nextTick();
const { relatedIssueRemoveRequest } = wrapper.emitted();
@@ -169,4 +174,23 @@ describe('RelatedIssuableItem', () => {
expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]);
});
});
+
+ describe('when issue is locked', () => {
+ const lockedMessage = 'Issues created from a vulnerability cannot be removed';
+
+ beforeEach(() => {
+ wrapper.setProps({
+ isLocked: true,
+ lockedMessage,
+ });
+ });
+
+ it('does not render the remove button', () => {
+ expect(findRemoveButton().exists()).toBe(false);
+ });
+
+ it('renders the lock icon with the correct title', () => {
+ expect(findLockIcon().attributes('title')).toBe(lockedMessage);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index 551d781d296..82bc9b9fe08 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -22,6 +22,12 @@ describe('Markdown field header component', () => {
.at(0);
beforeEach(() => {
+ window.gl = {
+ client: {
+ isMac: true,
+ },
+ };
+
createWrapper();
});
@@ -30,24 +36,40 @@ describe('Markdown field header component', () => {
wrapper = null;
});
- it('renders markdown header buttons', () => {
- const buttons = [
- 'Add bold text',
- 'Add italic text',
- 'Insert a quote',
- 'Insert suggestion',
- 'Insert code',
- 'Add a link',
- 'Add a bullet list',
- 'Add a numbered list',
- 'Add a task list',
- 'Add a table',
- 'Go full screen',
- ];
- const elements = findToolbarButtons();
-
- elements.wrappers.forEach((buttonEl, index) => {
- expect(buttonEl.props('buttonTitle')).toBe(buttons[index]);
+ describe('markdown header buttons', () => {
+ it('renders the buttons with the correct title', () => {
+ const buttons = [
+ 'Add bold text (⌘B)',
+ 'Add italic text (⌘I)',
+ 'Insert a quote',
+ 'Insert suggestion',
+ 'Insert code',
+ 'Add a link (⌘K)',
+ 'Add a bullet list',
+ 'Add a numbered list',
+ 'Add a task list',
+ 'Add a table',
+ 'Go full screen',
+ ];
+ const elements = findToolbarButtons();
+
+ elements.wrappers.forEach((buttonEl, index) => {
+ expect(buttonEl.props('buttonTitle')).toBe(buttons[index]);
+ });
+ });
+
+ describe('when the user is on a non-Mac', () => {
+ beforeEach(() => {
+ delete window.gl.client.isMac;
+
+ createWrapper();
+ });
+
+ it('renders keyboard shortcuts with Ctrl+ instead of ⌘', () => {
+ const boldButton = findToolbarButtonByProp('icon', 'bold');
+
+ expect(boldButton.props('buttonTitle')).toBe('Add bold text (Ctrl+B)');
+ });
});
});
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 c6e147899e4..a521668b15c 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
@@ -77,10 +77,7 @@ describe('Suggestion Diff component', () => {
});
it('emits apply', () => {
- expect(wrapper.emittedByOrder()).toContainEqual({
- name: 'apply',
- args: [expect.any(Function)],
- });
+ expect(wrapper.emitted().apply).toEqual([[expect.any(Function)]]);
});
it('does not render apply suggestion and add to batch buttons', () => {
@@ -111,10 +108,7 @@ describe('Suggestion Diff component', () => {
findAddToBatchButton().vm.$emit('click');
- expect(wrapper.emittedByOrder()).toContainEqual({
- name: 'addToBatch',
- args: [],
- });
+ expect(wrapper.emitted().addToBatch).toEqual([[]]);
});
});
@@ -124,10 +118,7 @@ describe('Suggestion Diff component', () => {
findRemoveFromBatchButton().vm.$emit('click');
- expect(wrapper.emittedByOrder()).toContainEqual({
- name: 'removeFromBatch',
- args: [],
- });
+ expect(wrapper.emitted().removeFromBatch).toEqual([[]]);
});
});
@@ -137,10 +128,7 @@ describe('Suggestion Diff component', () => {
findApplyBatchButton().vm.$emit('click');
- expect(wrapper.emittedByOrder()).toContainEqual({
- name: 'applyBatch',
- args: [],
- });
+ expect(wrapper.emitted().applyBatch).toEqual([[]]);
});
});
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 6ae405017c9..b67f4cf12bf 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
@@ -34,12 +34,25 @@ describe('SuggestionDiffRow', () => {
const findOldLineWrapper = () => wrapper.find('.old_line');
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({
+ propsData: {
+ line: oldLine,
+ },
+ });
+
+ expect(findSuggestionContent().html()).toBe(
+ '<td data-testid="suggestion-diff-content" class="line_content old"><span class="line">oldrichtext</span></td>',
+ );
+ });
+
it('has the right classes on the wrapper', () => {
factory({
propsData: {
@@ -47,7 +60,12 @@ describe('SuggestionDiffRow', () => {
},
});
- expect(wrapper.is('.line_holder')).toBe(true);
+ expect(wrapper.classes()).toContain('line_holder');
+ expect(
+ findSuggestionContent()
+ .find('span')
+ .classes(),
+ ).toContain('line');
});
it('renders the rich text when it is available', () => {
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
new file mode 100644
index 00000000000..8a7946fd7b1
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
@@ -0,0 +1,47 @@
+import { shallowMount } from '@vue/test-utils';
+import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue';
+
+describe('toolbar_button', () => {
+ let wrapper;
+
+ const defaultProps = {
+ buttonTitle: 'test button',
+ icon: 'rocket',
+ tag: 'test tag',
+ };
+
+ const createComponent = propUpdates => {
+ wrapper = shallowMount(ToolbarButton, {
+ propsData: {
+ ...defaultProps,
+ ...propUpdates,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const getButtonShortcutsAttr = () => {
+ return wrapper.find('button').attributes('data-md-shortcuts');
+ };
+
+ describe('keyboard shortcuts', () => {
+ it.each`
+ shortcutsProp | mdShortcutsAttr
+ ${undefined} | ${JSON.stringify([])}
+ ${[]} | ${JSON.stringify([])}
+ ${'command+b'} | ${JSON.stringify(['command+b'])}
+ ${['command+b', 'ctrl+b']} | ${JSON.stringify(['command+b', 'ctrl+b'])}
+ `(
+ 'adds the attribute data-md-shortcuts="$mdShortcutsAttr" to the button when the shortcuts prop is $shortcutsProp',
+ ({ shortcutsProp, mdShortcutsAttr }) => {
+ createComponent({ shortcuts: shortcutsProp });
+
+ expect(getButtonShortcutsAttr()).toBe(mdShortcutsAttr);
+ },
+ );
+ });
+});
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 ae8c9a0928e..61660f79b71 100644
--- a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js
+++ b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js
@@ -1,11 +1,11 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
-import Icon from '~/vue_shared/components/icon.vue';
describe('Issue Warning Component', () => {
let wrapper;
- const findIcon = (w = wrapper) => w.find(Icon);
+ const findIcon = (w = wrapper) => w.find(GlIcon);
const findLockedBlock = (w = wrapper) => w.find({ ref: 'locked' });
const findConfidentialBlock = (w = wrapper) => w.find({ ref: 'confidential' });
const findLockedAndConfidentialBlock = (w = wrapper) => w.find({ ref: 'lockedAndConfidential' });
@@ -69,7 +69,7 @@ describe('Issue Warning Component', () => {
});
it('renders warning icon', () => {
- expect(wrapper.find(Icon).exists()).toBe(true);
+ expect(wrapper.find(GlIcon).exists()).toBe(true);
});
it('does not render information about locked noteable', () => {
@@ -95,7 +95,7 @@ describe('Issue Warning Component', () => {
});
it('does not render warning icon', () => {
- expect(wrapper.find(Icon).exists()).toBe(false);
+ expect(wrapper.find(GlIcon).exists()).toBe(false);
});
it('does not render information about locked noteable', () => {
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 f73d3edec5d..bd4b6a463ab 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
@@ -17,9 +17,9 @@ describe(`TimelineEntryItem`, () => {
it('renders correctly', () => {
factory();
- expect(wrapper.is('.timeline-entry')).toBe(true);
+ expect(wrapper.classes()).toContain('timeline-entry');
- expect(wrapper.contains('.timeline-entry-inner')).toBe(true);
+ expect(wrapper.find('.timeline-entry-inner').exists()).toBe(true);
});
it('accepts default slot', () => {
diff --git a/spec/frontend/vue_shared/components/ordered_layout_spec.js b/spec/frontend/vue_shared/components/ordered_layout_spec.js
index e8667d9ee4a..eec153c3792 100644
--- a/spec/frontend/vue_shared/components/ordered_layout_spec.js
+++ b/spec/frontend/vue_shared/components/ordered_layout_spec.js
@@ -27,7 +27,9 @@ describe('Ordered Layout', () => {
let wrapper;
const verifyOrder = () =>
- wrapper.findAll('footer,header').wrappers.map(x => (x.is('footer') ? 'footer' : 'header'));
+ wrapper
+ .findAll('footer,header')
+ .wrappers.map(x => (x.element.tagName === 'FOOTER' ? 'footer' : 'header'));
const createComponent = (props = {}) => {
wrapper = mount(TestComponent, {
diff --git a/spec/frontend/vue_shared/components/paginated_list_spec.js b/spec/frontend/vue_shared/components/paginated_list_spec.js
index 46e45296c37..c0ee49f194f 100644
--- a/spec/frontend/vue_shared/components/paginated_list_spec.js
+++ b/spec/frontend/vue_shared/components/paginated_list_spec.js
@@ -48,7 +48,7 @@ describe('Pagination links component', () => {
describe('rendering', () => {
it('it renders the gl-paginated-list', () => {
- expect(wrapper.contains('ul.list-group')).toBe(true);
+ expect(wrapper.find('ul.list-group').exists()).toBe(true);
expect(wrapper.findAll('li.list-group-item').length).toBe(2);
});
});
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 385134c4a3f..649eb2643f1 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
@@ -29,7 +29,7 @@ describe('ProjectListItem component', () => {
it('does not render a check mark icon if selected === false', () => {
wrapper = shallowMount(Component, options);
- expect(wrapper.contains('.js-selected-icon.js-unselected')).toBe(true);
+ expect(wrapper.find('.js-selected-icon').exists()).toBe(false);
});
it('renders a check mark icon if selected === true', () => {
@@ -37,7 +37,7 @@ describe('ProjectListItem component', () => {
wrapper = shallowMount(Component, options);
- expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true);
+ expect(wrapper.find('.js-selected-icon').exists()).toBe(true);
});
it(`emits a "clicked" event when clicked`, () => {
@@ -53,7 +53,7 @@ describe('ProjectListItem component', () => {
it(`renders the project avatar`, () => {
wrapper = shallowMount(Component, options);
- expect(wrapper.contains('.js-project-avatar')).toBe(true);
+ expect(wrapper.find('.js-project-avatar').exists()).toBe(true);
});
it(`renders a simple namespace name with a trailing slash`, () => {
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
new file mode 100644
index 00000000000..16094a42668
--- /dev/null
+++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
@@ -0,0 +1,60 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Package code instruction multiline to match the snapshot 1`] = `
+<div>
+ <pre
+ class="gl-font-monospace"
+ data-testid="multiline-instruction"
+ >
+ this is some
+multiline text
+ </pre>
+</div>
+`;
+
+exports[`Package code instruction single line to match the default snapshot 1`] = `
+<div
+ class="gl-mb-3"
+>
+ <label
+ for="instruction-input_2"
+ >
+ foo_label
+ </label>
+
+ <div
+ class="input-group gl-mb-3"
+ >
+ <input
+ class="form-control gl-font-monospace"
+ data-testid="instruction-input"
+ id="instruction-input_2"
+ readonly="readonly"
+ type="text"
+ />
+
+ <span
+ class="input-group-append"
+ data-testid="instruction-button"
+ >
+ <button
+ class="btn input-group-text btn-secondary btn-md btn-default"
+ data-clipboard-text="npm i @my-package"
+ title="Copy npm install command"
+ type="button"
+ >
+ <!---->
+
+ <svg
+ class="gl-icon s16"
+ data-testid="copy-to-clipboard-icon"
+ >
+ <use
+ href="#copy-to-clipboard"
+ />
+ </svg>
+ </button>
+ </span>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap
index a1751d69c70..2abae33bc19 100644
--- a/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap
+++ b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`History Element renders the correct markup 1`] = `
+exports[`History Item renders the correct markup 1`] = `
<li
class="timeline-entry system-note note-wrapper gl-mb-6!"
>
@@ -31,7 +31,11 @@ exports[`History Element renders the correct markup 1`] = `
<div
class="note-body"
- />
+ >
+ <div
+ data-testid="body-slot"
+ />
+ </div>
</div>
</div>
</li>
diff --git a/spec/frontend/packages/details/components/code_instruction_spec.js b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
index 724eddb9070..84c738764a3 100644
--- a/spec/frontend/packages/details/components/code_instruction_spec.js
+++ b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils';
-import CodeInstruction from '~/packages/details/components/code_instruction.vue';
-import { TrackingLabels } from '~/packages/details/constants';
import Tracking from '~/tracking';
+import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
describe('Package code instruction', () => {
let wrapper;
@@ -20,16 +20,20 @@ describe('Package code instruction', () => {
});
}
- const findInstructionInput = () => wrapper.find('.js-instruction-input');
- const findInstructionPre = () => wrapper.find('.js-instruction-pre');
- const findInstructionButton = () => wrapper.find('.js-instruction-button');
+ const findCopyButton = () => wrapper.find(ClipboardButton);
+ const findInputElement = () => wrapper.find('[data-testid="instruction-input"]');
+ const findMultilineInstruction = () => wrapper.find('[data-testid="multiline-instruction"]');
afterEach(() => {
wrapper.destroy();
});
describe('single line', () => {
- beforeEach(() => createComponent());
+ beforeEach(() =>
+ createComponent({
+ label: 'foo_label',
+ }),
+ );
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
@@ -41,6 +45,7 @@ describe('Package code instruction', () => {
createComponent({
instruction: 'this is some\nmultiline text',
copyText: 'Copy the command',
+ label: 'foo_label',
multiline: true,
}),
);
@@ -53,7 +58,7 @@ describe('Package code instruction', () => {
describe('tracking', () => {
let eventSpy;
const trackingAction = 'test_action';
- const label = TrackingLabels.CODE_INSTRUCTION;
+ const trackingLabel = 'foo_label';
beforeEach(() => {
eventSpy = jest.spyOn(Tracking, 'event');
@@ -61,7 +66,7 @@ describe('Package code instruction', () => {
it('should not track when no trackingAction is provided', () => {
createComponent();
- findInstructionButton().trigger('click');
+ findCopyButton().trigger('click');
expect(eventSpy).toHaveBeenCalledTimes(0);
});
@@ -70,22 +75,23 @@ describe('Package code instruction', () => {
beforeEach(() =>
createComponent({
trackingAction,
+ trackingLabel,
}),
);
it('should track when copying from the input', () => {
- findInstructionInput().trigger('copy');
+ findInputElement().trigger('copy');
expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, {
- label,
+ label: trackingLabel,
});
});
it('should track when the copy button is pressed', () => {
- findInstructionButton().trigger('click');
+ findCopyButton().trigger('click');
expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, {
- label,
+ label: trackingLabel,
});
});
});
@@ -94,15 +100,16 @@ describe('Package code instruction', () => {
beforeEach(() =>
createComponent({
trackingAction,
+ trackingLabel,
multiline: true,
}),
);
it('should track when copying from the multiline pre element', () => {
- findInstructionPre().trigger('copy');
+ findMultilineInstruction().trigger('copy');
expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, {
- label,
+ label: trackingLabel,
});
});
});
diff --git a/spec/frontend/registry/shared/components/details_row_spec.js b/spec/frontend/vue_shared/components/registry/details_row_spec.js
index 5ae4e0ab37f..16a55b84787 100644
--- a/spec/frontend/registry/shared/components/details_row_spec.js
+++ b/spec/frontend/vue_shared/components/registry/details_row_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
-import component from '~/registry/shared/components/details_row.vue';
+import component from '~/vue_shared/components/registry/details_row.vue';
describe('DetailsRow', () => {
let wrapper;
diff --git a/spec/frontend/packages/details/components/history_element_spec.js b/spec/frontend/vue_shared/components/registry/history_item_spec.js
index e8746fc93f5..d51ddda2e3e 100644
--- a/spec/frontend/packages/details/components/history_element_spec.js
+++ b/spec/frontend/vue_shared/components/registry/history_item_spec.js
@@ -1,9 +1,9 @@
import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
-import component from '~/packages/details/components/history_element.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import component from '~/vue_shared/components/registry/history_item.vue';
-describe('History Element', () => {
+describe('History Item', () => {
let wrapper;
const defaultProps = {
icon: 'pencil',
@@ -17,6 +17,7 @@ describe('History Element', () => {
},
slots: {
default: '<div data-testid="default-slot"></div>',
+ body: '<div data-testid="body-slot"></div>',
},
});
};
@@ -29,6 +30,7 @@ describe('History Element', () => {
const findTimelineEntry = () => wrapper.find(TimelineEntryItem);
const findGlIcon = () => wrapper.find(GlIcon);
const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]');
+ const findBodySlot = () => wrapper.find('[data-testid="body-slot"]');
it('renders the correct markup', () => {
mountComponent();
@@ -41,11 +43,19 @@ describe('History Element', () => {
expect(findDefaultSlot().exists()).toBe(true);
});
+
+ it('has a body slot', () => {
+ mountComponent();
+
+ expect(findBodySlot().exists()).toBe(true);
+ });
+
it('has a timeline entry', () => {
mountComponent();
expect(findTimelineEntry().exists()).toBe(true);
});
+
it('has an icon', () => {
mountComponent();
diff --git a/spec/frontend/registry/explorer/components/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js
index f244627a8c3..e2cfdedb4bf 100644
--- a/spec/frontend/registry/explorer/components/list_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js
@@ -1,6 +1,6 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import component from '~/registry/explorer/components/list_item.vue';
+import component from '~/vue_shared/components/registry/list_item.vue';
describe('list item', () => {
let wrapper;
@@ -34,7 +34,7 @@ describe('list item', () => {
wrapper = null;
});
- it.each`
+ describe.each`
slotName | finderFunction
${'left-primary'} | ${findLeftPrimarySlot}
${'left-secondary'} | ${findLeftSecondarySlot}
@@ -42,10 +42,18 @@ describe('list item', () => {
${'right-secondary'} | ${findRightSecondarySlot}
${'left-action'} | ${findLeftActionSlot}
${'right-action'} | ${findRightActionSlot}
- `('has a $slotName slot', ({ finderFunction }) => {
- mountComponent();
+ `('$slotName slot', ({ finderFunction, slotName }) => {
+ it('exist when the slot is filled', () => {
+ mountComponent();
+
+ expect(finderFunction().exists()).toBe(true);
+ });
- expect(finderFunction().exists()).toBe(true);
+ it('does not exist when the slot is empty', () => {
+ mountComponent({}, { [slotName]: '' });
+
+ expect(finderFunction().exists()).toBe(false);
+ });
});
describe.each`
@@ -106,51 +114,22 @@ describe('list item', () => {
});
});
- describe('first prop', () => {
- it('when is true displays a double top border', () => {
- mountComponent({ first: true });
-
- expect(wrapper.classes('gl-border-t-2')).toBe(true);
- });
-
- it('when is false display a single top border', () => {
- mountComponent({ first: false });
-
- expect(wrapper.classes('gl-border-t-1')).toBe(true);
- });
- });
-
- describe('last prop', () => {
- it('when is true displays a double bottom border', () => {
- mountComponent({ last: true });
-
- expect(wrapper.classes('gl-border-b-2')).toBe(true);
- });
-
- it('when is false display a single bottom border', () => {
- mountComponent({ last: false });
-
- expect(wrapper.classes('gl-border-b-1')).toBe(true);
- });
- });
-
- describe('selected prop', () => {
- it('when true applies the selected border and background', () => {
- mountComponent({ selected: true });
-
- expect(wrapper.classes()).toEqual(
- expect.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']),
- );
- expect(wrapper.classes()).toEqual(expect.not.arrayContaining(['gl-border-gray-100']));
- });
-
- it('when false applies the default border', () => {
- mountComponent({ selected: false });
-
- expect(wrapper.classes()).toEqual(
- expect.not.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']),
- );
- expect(wrapper.classes()).toEqual(expect.arrayContaining(['gl-border-gray-100']));
- });
+ describe('borders and selection', () => {
+ it.each`
+ first | selected | shouldHave | shouldNotHave
+ ${true} | ${true} | ${['gl-bg-blue-50', 'gl-border-blue-200']} | ${['gl-border-t-transparent', 'gl-border-t-gray-100']}
+ ${false} | ${true} | ${['gl-bg-blue-50', 'gl-border-blue-200']} | ${['gl-border-t-transparent', 'gl-border-t-gray-100']}
+ ${true} | ${false} | ${['gl-border-b-gray-100']} | ${['gl-bg-blue-50', 'gl-border-blue-200']}
+ ${false} | ${false} | ${['gl-border-b-gray-100']} | ${['gl-bg-blue-50', 'gl-border-blue-200']}
+ `(
+ 'when first is $first and selected is $selected',
+ ({ first, selected, shouldHave, shouldNotHave }) => {
+ mountComponent({ first, selected });
+
+ expect(wrapper.classes()).toEqual(expect.arrayContaining(shouldHave));
+
+ expect(wrapper.classes()).toEqual(expect.not.arrayContaining(shouldNotHave));
+ },
+ );
});
});
diff --git a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js
new file mode 100644
index 00000000000..ff968ff1831
--- /dev/null
+++ b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js
@@ -0,0 +1,101 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon, GlLink } from '@gitlab/ui';
+import component from '~/vue_shared/components/registry/metadata_item.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+
+describe('Metadata Item', () => {
+ let wrapper;
+ const defaultProps = {
+ text: 'foo',
+ };
+
+ const mountComponent = (propsData = defaultProps) => {
+ wrapper = shallowMount(component, {
+ propsData,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findIcon = () => wrapper.find(GlIcon);
+ const findLink = (w = wrapper) => w.find(GlLink);
+ const findText = () => wrapper.find('[data-testid="metadata-item-text"]');
+ const findTooltipOnTruncate = (w = wrapper) => w.find(TooltipOnTruncate);
+
+ describe.each(['xs', 's', 'm', 'l', 'xl'])('size class', size => {
+ const className = `mw-${size}`;
+
+ it(`${size} is assigned correctly to text`, () => {
+ mountComponent({ ...defaultProps, size });
+
+ expect(findText().classes()).toContain(className);
+ });
+
+ it(`${size} is assigned correctly to link`, () => {
+ mountComponent({ ...defaultProps, link: 'foo', size });
+
+ expect(findTooltipOnTruncate().classes()).toContain(className);
+ });
+ });
+
+ describe('text', () => {
+ it('display a proper text', () => {
+ mountComponent();
+
+ expect(findText().text()).toBe(defaultProps.text);
+ });
+
+ it('uses tooltip_on_truncate', () => {
+ mountComponent();
+
+ const tooltip = findTooltipOnTruncate(findText());
+ expect(tooltip.exists()).toBe(true);
+ expect(tooltip.attributes('title')).toBe(defaultProps.text);
+ });
+ });
+
+ describe('link', () => {
+ it('if a link prop is passed shows a link and hides the text', () => {
+ mountComponent({ ...defaultProps, link: 'bar' });
+
+ expect(findLink().exists()).toBe(true);
+ expect(findText().exists()).toBe(false);
+
+ expect(findLink().attributes('href')).toBe('bar');
+ });
+
+ it('uses tooltip_on_truncate', () => {
+ mountComponent({ ...defaultProps, link: 'bar' });
+
+ const tooltip = findTooltipOnTruncate();
+ expect(tooltip.exists()).toBe(true);
+ expect(tooltip.attributes('title')).toBe(defaultProps.text);
+ expect(findLink(tooltip).exists()).toBe(true);
+ });
+
+ it('hides the link and shows the test if a link prop is not passed', () => {
+ mountComponent();
+
+ expect(findText().exists()).toBe(true);
+ expect(findLink().exists()).toBe(false);
+ });
+ });
+
+ describe('icon', () => {
+ it('if a icon prop is passed shows a icon', () => {
+ mountComponent({ ...defaultProps, icon: 'pencil' });
+
+ expect(findIcon().exists()).toBe(true);
+ expect(findIcon().props('name')).toBe('pencil');
+ });
+
+ it('if a icon prop is not passed hides the icon', () => {
+ mountComponent();
+
+ expect(findIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js
new file mode 100644
index 00000000000..6740d6097a4
--- /dev/null
+++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js
@@ -0,0 +1,98 @@
+import { GlAvatar } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import component from '~/vue_shared/components/registry/title_area.vue';
+
+describe('title area', () => {
+ let wrapper;
+
+ const findSubHeaderSlot = () => wrapper.find('[data-testid="sub-header"]');
+ const findRightActionsSlot = () => wrapper.find('[data-testid="right-actions"]');
+ const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`);
+ const findTitle = () => wrapper.find('[data-testid="title"]');
+ const findAvatar = () => wrapper.find(GlAvatar);
+
+ const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => {
+ wrapper = shallowMount(component, {
+ propsData,
+ slots: {
+ 'sub-header': '<div data-testid="sub-header" />',
+ 'right-actions': '<div data-testid="right-actions" />',
+ ...slots,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('title', () => {
+ it('if slot is not present defaults to prop', () => {
+ mountComponent();
+
+ expect(findTitle().text()).toBe('foo');
+ });
+ it('if slot is present uses slot', () => {
+ mountComponent({
+ slots: {
+ title: 'slot_title',
+ },
+ });
+ expect(findTitle().text()).toBe('slot_title');
+ });
+ });
+
+ describe('avatar', () => {
+ it('is shown if avatar props exist', () => {
+ mountComponent({ propsData: { title: 'foo', avatar: 'baz' } });
+
+ expect(findAvatar().props('src')).toBe('baz');
+ });
+
+ it('is hidden if avatar props does not exist', () => {
+ mountComponent();
+
+ expect(findAvatar().exists()).toBe(false);
+ });
+ });
+
+ describe.each`
+ slotName | finderFunction
+ ${'sub-header'} | ${findSubHeaderSlot}
+ ${'right-actions'} | ${findRightActionsSlot}
+ `('$slotName slot', ({ finderFunction, slotName }) => {
+ it('exist when the slot is filled', () => {
+ mountComponent();
+
+ expect(finderFunction().exists()).toBe(true);
+ });
+
+ it('does not exist when the slot is empty', () => {
+ mountComponent({ slots: { [slotName]: '' } });
+
+ expect(finderFunction().exists()).toBe(false);
+ });
+ });
+
+ describe.each`
+ slotNames
+ ${['metadata_foo']}
+ ${['metadata_foo', 'metadata_bar']}
+ ${['metadata_foo', 'metadata_bar', 'metadata_baz']}
+ `('$slotNames metadata slots', ({ slotNames }) => {
+ const slotMocks = slotNames.reduce((acc, current) => {
+ acc[current] = `<div data-testid="${current}" />`;
+ return acc;
+ }, {});
+
+ it('exist when the slot is present', async () => {
+ mountComponent({ slots: slotMocks });
+
+ await wrapper.vm.$nextTick();
+ slotNames.forEach(name => {
+ expect(findMetadataSlot(name).exists()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/vue_shared/components/remove_member_modal_spec.js
index 2d380b25a0a..78fe6d53eee 100644
--- a/spec/frontend/vue_shared/components/remove_member_modal_spec.js
+++ b/spec/frontend/vue_shared/components/remove_member_modal_spec.js
@@ -48,7 +48,7 @@ describe('RemoveMemberModal', () => {
});
it(`${checkboxTestDescription}`, () => {
- expect(wrapper.contains(GlFormCheckbox)).toBe(checkboxExpected);
+ expect(wrapper.find(GlFormCheckbox).exists()).toBe(checkboxExpected);
});
it('submits the form when the modal is submitted', () => {
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 103b53cb280..3990248d021 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
@@ -1,324 +1,433 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Resizable Skeleton Loader default setup renders the bars, labels, and grid with correct position, size, and rx percentages 1`] = `
-<gl-skeleton-loader-stub
- baseurl=""
- height="130"
- preserveaspectratio="xMidYMid meet"
- width="400"
+<div
+ class="gl-px-8"
>
- <rect
- data-testid="skeleton-chart-grid"
- height="1px"
- width="100%"
- x="0"
- y="30%"
- />
- <rect
- data-testid="skeleton-chart-grid"
- height="1px"
- width="100%"
- x="0"
- y="60%"
- />
- <rect
- data-testid="skeleton-chart-grid"
- height="1px"
- width="100%"
- x="0"
- y="90%"
- />
-
- <rect
- data-testid="skeleton-chart-bar"
- height="5%"
- rx="0.4%"
- width="6%"
- x="5.875%"
- y="85%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="7%"
- rx="0.4%"
- width="6%"
- x="17.625%"
- y="83%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="9%"
- rx="0.4%"
- width="6%"
- x="29.375%"
- y="81%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="14%"
- rx="0.4%"
- width="6%"
- x="41.125%"
- y="76%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="21%"
- rx="0.4%"
- width="6%"
- x="52.875%"
- y="69%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="35%"
- rx="0.4%"
- width="6%"
- x="64.625%"
- y="55%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="50%"
- rx="0.4%"
- width="6%"
- x="76.375%"
- y="40%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="80%"
- rx="0.4%"
- width="6%"
- x="88.125%"
- y="10%"
- />
-
- <rect
- data-testid="skeleton-chart-label"
- height="5%"
- rx="0.4%"
- width="4%"
- x="6.875%"
- y="95%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="5%"
- rx="0.4%"
- width="4%"
- x="18.625%"
- y="95%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="5%"
- rx="0.4%"
- width="4%"
- x="30.375%"
- y="95%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="5%"
- rx="0.4%"
- width="4%"
- x="42.125%"
- y="95%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="5%"
- rx="0.4%"
- width="4%"
- x="53.875%"
- y="95%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="5%"
- rx="0.4%"
- width="4%"
- x="65.625%"
- y="95%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="5%"
- rx="0.4%"
- width="4%"
- x="77.375%"
- y="95%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="5%"
- rx="0.4%"
- width="4%"
- x="89.125%"
- y="95%"
- />
-</gl-skeleton-loader-stub>
+ <svg
+ class="gl-skeleton-loader"
+ preserveAspectRatio="xMidYMid meet"
+ version="1.1"
+ viewBox="0 0 400 130"
+ >
+ <rect
+ clip-path="url(#null-idClip)"
+ height="130"
+ style="fill: url(#null-idGradient);"
+ width="400"
+ x="0"
+ y="0"
+ />
+ <defs>
+ <clippath
+ id="null-idClip"
+ >
+ <rect
+ data-testid="skeleton-chart-grid"
+ height="1px"
+ width="100%"
+ x="0"
+ y="30%"
+ />
+ <rect
+ data-testid="skeleton-chart-grid"
+ height="1px"
+ width="100%"
+ x="0"
+ y="60%"
+ />
+ <rect
+ data-testid="skeleton-chart-grid"
+ height="1px"
+ width="100%"
+ x="0"
+ y="90%"
+ />
+
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="5%"
+ rx="0.4%"
+ width="4%"
+ x="6%"
+ y="85%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="7%"
+ rx="0.4%"
+ width="4%"
+ x="18%"
+ y="83%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="9%"
+ rx="0.4%"
+ width="4%"
+ x="30%"
+ y="81%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="14%"
+ rx="0.4%"
+ width="4%"
+ x="42%"
+ y="76%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="21%"
+ rx="0.4%"
+ width="4%"
+ x="54%"
+ y="69%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="35%"
+ rx="0.4%"
+ width="4%"
+ x="66%"
+ y="55%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="50%"
+ rx="0.4%"
+ width="4%"
+ x="78%"
+ y="40%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="80%"
+ rx="0.4%"
+ width="4%"
+ x="90%"
+ y="10%"
+ />
+
+ <rect
+ data-testid="skeleton-chart-label"
+ height="3%"
+ rx="0.4%"
+ width="3%"
+ x="6.5%"
+ y="97%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="3%"
+ rx="0.4%"
+ width="3%"
+ x="18.5%"
+ y="97%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="3%"
+ rx="0.4%"
+ width="3%"
+ x="30.5%"
+ y="97%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="3%"
+ rx="0.4%"
+ width="3%"
+ x="42.5%"
+ y="97%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="3%"
+ rx="0.4%"
+ width="3%"
+ x="54.5%"
+ y="97%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="3%"
+ rx="0.4%"
+ width="3%"
+ x="66.5%"
+ y="97%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="3%"
+ rx="0.4%"
+ width="3%"
+ x="78.5%"
+ y="97%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="3%"
+ rx="0.4%"
+ width="3%"
+ x="90.5%"
+ y="97%"
+ />
+ </clippath>
+ <lineargradient
+ id="null-idGradient"
+ >
+ <stop
+ class="primary-stop"
+ offset="0%"
+ >
+ <animate
+ attributeName="offset"
+ dur="1s"
+ repeatCount="indefinite"
+ values="-2; 1"
+ />
+ </stop>
+ <stop
+ class="secondary-stop"
+ offset="50%"
+ >
+ <animate
+ attributeName="offset"
+ dur="1s"
+ repeatCount="indefinite"
+ values="-1.5; 1.5"
+ />
+ </stop>
+ <stop
+ class="primary-stop"
+ offset="100%"
+ >
+ <animate
+ attributeName="offset"
+ dur="1s"
+ repeatCount="indefinite"
+ values="-1; 2"
+ />
+ </stop>
+ </lineargradient>
+ </defs>
+ </svg>
+</div>
`;
exports[`Resizable Skeleton Loader with custom settings renders the correct position, and size percentages for bars and labels with different settings 1`] = `
-<gl-skeleton-loader-stub
- baseurl=""
- height="130"
- preserveaspectratio="xMidYMid meet"
- uniquekey=""
- width="400"
+<div
+ class="gl-px-8"
>
- <rect
- data-testid="skeleton-chart-grid"
- height="1px"
- width="100%"
- x="0"
- y="30%"
- />
- <rect
- data-testid="skeleton-chart-grid"
- height="1px"
- width="100%"
- x="0"
- y="60%"
- />
- <rect
- data-testid="skeleton-chart-grid"
- height="1px"
- width="100%"
- x="0"
- y="90%"
- />
-
- <rect
- data-testid="skeleton-chart-bar"
- height="5%"
- rx="0.6%"
- width="3%"
- x="6.0625%"
- y="85%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="7%"
- rx="0.6%"
- width="3%"
- x="18.1875%"
- y="83%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="9%"
- rx="0.6%"
- width="3%"
- x="30.3125%"
- y="81%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="14%"
- rx="0.6%"
- width="3%"
- x="42.4375%"
- y="76%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="21%"
- rx="0.6%"
- width="3%"
- x="54.5625%"
- y="69%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="35%"
- rx="0.6%"
- width="3%"
- x="66.6875%"
- y="55%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="50%"
- rx="0.6%"
- width="3%"
- x="78.8125%"
- y="40%"
- />
- <rect
- data-testid="skeleton-chart-bar"
- height="80%"
- rx="0.6%"
- width="3%"
- x="90.9375%"
- y="10%"
- />
-
- <rect
- data-testid="skeleton-chart-label"
- height="2%"
- rx="0.6%"
- width="7%"
- x="4.0625%"
- y="98%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="2%"
- rx="0.6%"
- width="7%"
- x="16.1875%"
- y="98%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="2%"
- rx="0.6%"
- width="7%"
- x="28.3125%"
- y="98%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="2%"
- rx="0.6%"
- width="7%"
- x="40.4375%"
- y="98%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="2%"
- rx="0.6%"
- width="7%"
- x="52.5625%"
- y="98%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="2%"
- rx="0.6%"
- width="7%"
- x="64.6875%"
- y="98%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="2%"
- rx="0.6%"
- width="7%"
- x="76.8125%"
- y="98%"
- />
- <rect
- data-testid="skeleton-chart-label"
- height="2%"
- rx="0.6%"
- width="7%"
- x="88.9375%"
- y="98%"
- />
-</gl-skeleton-loader-stub>
+ <svg
+ class="gl-skeleton-loader"
+ preserveAspectRatio="xMidYMid meet"
+ version="1.1"
+ viewBox="0 0 400 130"
+ >
+ <rect
+ clip-path="url(#-idClip)"
+ height="130"
+ style="fill: url(#-idGradient);"
+ width="400"
+ x="0"
+ y="0"
+ />
+ <defs>
+ <clippath
+ id="-idClip"
+ >
+ <rect
+ data-testid="skeleton-chart-grid"
+ height="1px"
+ width="100%"
+ x="0"
+ y="30%"
+ />
+ <rect
+ data-testid="skeleton-chart-grid"
+ height="1px"
+ width="100%"
+ x="0"
+ y="60%"
+ />
+ <rect
+ data-testid="skeleton-chart-grid"
+ height="1px"
+ width="100%"
+ x="0"
+ y="90%"
+ />
+
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="5%"
+ rx="0.6%"
+ width="3%"
+ x="6.0625%"
+ y="85%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="7%"
+ rx="0.6%"
+ width="3%"
+ x="18.1875%"
+ y="83%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="9%"
+ rx="0.6%"
+ width="3%"
+ x="30.3125%"
+ y="81%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="14%"
+ rx="0.6%"
+ width="3%"
+ x="42.4375%"
+ y="76%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="21%"
+ rx="0.6%"
+ width="3%"
+ x="54.5625%"
+ y="69%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="35%"
+ rx="0.6%"
+ width="3%"
+ x="66.6875%"
+ y="55%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="50%"
+ rx="0.6%"
+ width="3%"
+ x="78.8125%"
+ y="40%"
+ />
+ <rect
+ data-testid="skeleton-chart-bar"
+ height="80%"
+ rx="0.6%"
+ width="3%"
+ x="90.9375%"
+ y="10%"
+ />
+
+ <rect
+ data-testid="skeleton-chart-label"
+ height="2%"
+ rx="0.6%"
+ width="7%"
+ x="4.0625%"
+ y="98%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="2%"
+ rx="0.6%"
+ width="7%"
+ x="16.1875%"
+ y="98%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="2%"
+ rx="0.6%"
+ width="7%"
+ x="28.3125%"
+ y="98%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="2%"
+ rx="0.6%"
+ width="7%"
+ x="40.4375%"
+ y="98%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="2%"
+ rx="0.6%"
+ width="7%"
+ x="52.5625%"
+ y="98%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="2%"
+ rx="0.6%"
+ width="7%"
+ x="64.6875%"
+ y="98%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="2%"
+ rx="0.6%"
+ width="7%"
+ x="76.8125%"
+ y="98%"
+ />
+ <rect
+ data-testid="skeleton-chart-label"
+ height="2%"
+ rx="0.6%"
+ width="7%"
+ x="88.9375%"
+ y="98%"
+ />
+ </clippath>
+ <lineargradient
+ id="-idGradient"
+ >
+ <stop
+ class="primary-stop"
+ offset="0%"
+ >
+ <animate
+ attributeName="offset"
+ dur="1s"
+ repeatCount="indefinite"
+ values="-2; 1"
+ />
+ </stop>
+ <stop
+ class="secondary-stop"
+ offset="50%"
+ >
+ <animate
+ attributeName="offset"
+ dur="1s"
+ repeatCount="indefinite"
+ values="-1.5; 1.5"
+ />
+ </stop>
+ <stop
+ class="primary-stop"
+ offset="100%"
+ >
+ <animate
+ attributeName="offset"
+ dur="1s"
+ repeatCount="indefinite"
+ values="-1; 2"
+ />
+ </stop>
+ </lineargradient>
+ </defs>
+ </svg>
+</div>
`;
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 7facd02e596..bfc3aeb0303 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
@@ -1,11 +1,11 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
describe('Resizable Skeleton Loader', () => {
let wrapper;
const createComponent = (propsData = {}) => {
- wrapper = shallowMount(ChartSkeletonLoader, {
+ wrapper = mount(ChartSkeletonLoader, {
propsData,
});
};
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js
index cafe53e6bb2..a823d04024d 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js
@@ -5,8 +5,13 @@ describe('Build Custom Renderer Service', () => {
it('should return an object with the default renderer functions when lacking arguments', () => {
expect(buildCustomHTMLRenderer()).toEqual(
expect.objectContaining({
- list: expect.any(Function),
+ htmlBlock: expect.any(Function),
+ htmlInline: expect.any(Function),
+ heading: expect.any(Function),
+ item: expect.any(Function),
+ paragraph: expect.any(Function),
text: expect.any(Function),
+ softbreak: expect.any(Function),
}),
);
});
@@ -20,8 +25,6 @@ describe('Build Custom Renderer Service', () => {
expect(buildCustomHTMLRenderer(customRenderers)).toEqual(
expect.objectContaining({
html: expect.any(Function),
- list: expect.any(Function),
- text: expect.any(Function),
}),
);
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
index a90d3528d60..fd745c21bb6 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
@@ -1,9 +1,10 @@
import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer';
+import { attributeDefinition } from './renderers/mock_data';
-describe('HTMLToMarkdownRenderer', () => {
+describe('rich_content_editor/services/html_to_markdown_renderer', () => {
let baseRenderer;
let htmlToMarkdownRenderer;
- const NODE = { nodeValue: 'mock_node' };
+ let fakeNode;
beforeEach(() => {
baseRenderer = {
@@ -12,14 +13,20 @@ describe('HTMLToMarkdownRenderer', () => {
getSpaceControlled: jest.fn(input => `space controlled ${input}`),
convert: jest.fn(),
};
+
+ fakeNode = { nodeValue: 'mock_node', dataset: {} };
+ });
+
+ afterEach(() => {
+ htmlToMarkdownRenderer = null;
});
describe('TEXT_NODE visitor', () => {
it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => {
htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
- expect(htmlToMarkdownRenderer.TEXT_NODE(NODE)).toBe(
- `space controlled trimmed space collapsed ${NODE.nodeValue}`,
+ expect(htmlToMarkdownRenderer.TEXT_NODE(fakeNode)).toBe(
+ `space controlled trimmed space collapsed ${fakeNode.nodeValue}`,
);
});
});
@@ -43,8 +50,8 @@ describe('HTMLToMarkdownRenderer', () => {
baseRenderer.convert.mockReturnValueOnce(list);
- expect(htmlToMarkdownRenderer['LI OL, LI UL'](NODE, list)).toBe(result);
- expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, list);
+ expect(htmlToMarkdownRenderer['LI OL, LI UL'](fakeNode, list)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, list);
});
});
@@ -62,10 +69,21 @@ describe('HTMLToMarkdownRenderer', () => {
});
baseRenderer.convert.mockReturnValueOnce(listItem);
- expect(htmlToMarkdownRenderer['UL LI'](NODE, listItem)).toBe(result);
- expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, listItem);
+ expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, listItem);
},
);
+
+ it('detects attribute definitions and attaches them to the list item', () => {
+ const listItem = '- list item';
+ const result = `${listItem}\n${attributeDefinition}\n`;
+
+ fakeNode.dataset.attributeDefinition = attributeDefinition;
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ baseRenderer.convert.mockReturnValueOnce(`${listItem}\n`);
+
+ expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result);
+ });
});
describe('OL LI visitor', () => {
@@ -85,8 +103,8 @@ describe('HTMLToMarkdownRenderer', () => {
});
baseRenderer.convert.mockReturnValueOnce(listItem);
- expect(htmlToMarkdownRenderer['OL LI'](NODE, subContent)).toBe(result);
- expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, subContent);
+ expect(htmlToMarkdownRenderer['OL LI'](fakeNode, subContent)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, subContent);
},
);
});
@@ -105,8 +123,8 @@ describe('HTMLToMarkdownRenderer', () => {
baseRenderer.convert.mockReturnValueOnce(input);
- expect(htmlToMarkdownRenderer['STRONG, B'](NODE, input)).toBe(result);
- expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input);
+ expect(htmlToMarkdownRenderer['STRONG, B'](fakeNode, input)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input);
},
);
});
@@ -125,9 +143,50 @@ describe('HTMLToMarkdownRenderer', () => {
baseRenderer.convert.mockReturnValueOnce(input);
- expect(htmlToMarkdownRenderer['EM, I'](NODE, input)).toBe(result);
- expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input);
+ expect(htmlToMarkdownRenderer['EM, I'](fakeNode, input)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input);
},
);
});
+
+ describe('H1, H2, H3, H4, H5, H6 visitor', () => {
+ it('detects attribute definitions and attaches them to the heading', () => {
+ const heading = 'heading text';
+ const result = `${heading.trimRight()}\n${attributeDefinition}\n\n`;
+
+ fakeNode.dataset.attributeDefinition = attributeDefinition;
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ baseRenderer.convert.mockReturnValueOnce(`${heading}\n\n`);
+
+ expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result);
+ });
+ });
+
+ describe('PRE CODE', () => {
+ let node;
+ const subContent = 'sub content';
+ const originalConverterResult = 'base result';
+
+ beforeEach(() => {
+ node = document.createElement('PRE');
+
+ node.innerText = 'reference definition content';
+ node.dataset.sseReferenceDefinition = true;
+
+ baseRenderer.convert.mockReturnValueOnce(originalConverterResult);
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ });
+
+ it('returns raw text when pre node has sse-reference-definitions class', () => {
+ expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(
+ `\n\n${node.innerText}\n\n`,
+ );
+ });
+
+ it('returns base result when pre node does not have sse-reference-definitions class', () => {
+ delete node.dataset.sseReferenceDefinition;
+
+ expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
index 660c21281fd..5cf3961819e 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
@@ -1,12 +1,6 @@
// Node spec helpers
-export const buildMockTextNode = literal => {
- return {
- firstChild: null,
- literal,
- type: 'text',
- };
-};
+export const buildMockTextNode = literal => ({ literal, type: 'text' });
export const normalTextNode = buildMockTextNode('This is just normal text.');
@@ -23,17 +17,20 @@ const buildMockUneditableOpenToken = type => {
};
};
-const buildMockUneditableCloseToken = type => {
- return { type: 'closeTag', tagName: type };
+const buildMockTextToken = content => {
+ return {
+ type: 'text',
+ tagName: null,
+ content,
+ };
};
-export const originToken = {
- type: 'text',
- tagName: null,
- content: '{:.no_toc .hidden-md .hidden-lg}',
-};
+const buildMockUneditableCloseToken = type => ({ type: 'closeTag', tagName: type });
+
+export const originToken = buildMockTextToken('{:.no_toc .hidden-md .hidden-lg}');
+const uneditableOpenToken = buildMockUneditableOpenToken('div');
+export const uneditableOpenTokens = [uneditableOpenToken, originToken];
export const uneditableCloseToken = buildMockUneditableCloseToken('div');
-export const uneditableOpenTokens = [buildMockUneditableOpenToken('div'), originToken];
export const uneditableCloseTokens = [originToken, uneditableCloseToken];
export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken];
@@ -41,6 +38,7 @@ export const originInlineToken = {
type: 'text',
content: '<i>Inline</i> content',
};
+
export const uneditableInlineTokens = [
buildMockUneditableOpenToken('a'),
originInlineToken,
@@ -48,11 +46,9 @@ export const uneditableInlineTokens = [
];
export const uneditableBlockTokens = [
- buildMockUneditableOpenToken('div'),
- {
- type: 'text',
- tagName: null,
- content: '<div><h1>Some header</h1><p>Some paragraph</p></div>',
- },
- buildMockUneditableCloseToken('div'),
+ uneditableOpenToken,
+ buildMockTextToken('<div><h1>Some header</h1><p>Some paragraph</p></div>'),
+ uneditableCloseToken,
];
+
+export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}';
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js
new file mode 100644
index 00000000000..69fd9a67a21
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js
@@ -0,0 +1,25 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition';
+import { attributeDefinition } from './mock_data';
+
+describe('rich_content_editor/renderers/render_attribute_definition', () => {
+ describe('canRender', () => {
+ it.each`
+ input | result
+ ${{ literal: attributeDefinition }} | ${true}
+ ${{ literal: `FOO${attributeDefinition}` }} | ${false}
+ ${{ literal: `${attributeDefinition}BAR` }} | ${false}
+ ${{ literal: 'foobar' }} | ${false}
+ `('returns $result when input is $input', ({ input, result }) => {
+ expect(renderer.canRender(input)).toBe(result);
+ });
+ });
+
+ describe('render', () => {
+ it('returns an empty HTML comment', () => {
+ expect(renderer.render()).toEqual({
+ type: 'html',
+ content: '<!-- sse-attribute-definition -->',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js
new file mode 100644
index 00000000000..76abc1ec3d8
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js
@@ -0,0 +1,12 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_heading';
+import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
+
+describe('rich_content_editor/renderers/render_heading', () => {
+ it('canRender delegates to renderUtils.willAlwaysRender', () => {
+ expect(renderer.canRender).toBe(renderUtils.willAlwaysRender);
+ });
+
+ it('render delegates to renderUtils.renderWithAttributeDefinitions', () => {
+ expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
index f4a06b91a10..b3d9576f38b 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
@@ -1,5 +1,4 @@
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph';
-import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
import { buildMockTextNode } from './mock_data';
@@ -17,7 +16,7 @@ const identifierParagraphNode = buildMockParagraphNode(
`[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`,
);
-describe('Render Identifier Paragraph renderer', () => {
+describe('rich_content_editor/renderers_render_identifier_paragraph', () => {
describe('canRender', () => {
it.each`
node | paragraph | target
@@ -37,8 +36,49 @@ describe('Render Identifier Paragraph renderer', () => {
});
describe('render', () => {
- it('should delegate rendering to the renderUneditableBranch util', () => {
- expect(renderer.render).toBe(renderUneditableBranch);
+ let context;
+ let result;
+
+ beforeEach(() => {
+ const node = {
+ firstChild: {
+ type: 'text',
+ literal: '[Some text]: https://link.com',
+ next: {
+ type: 'linebreak',
+ next: {
+ type: 'text',
+ literal: '[identifier]: http://example1.com "title"',
+ },
+ },
+ },
+ };
+ context = { skipChildren: jest.fn() };
+ result = renderer.render(node, context);
+ });
+
+ it('renders the reference definitions as a code block', () => {
+ expect(result).toEqual([
+ {
+ type: 'openTag',
+ tagName: 'pre',
+ classNames: ['code-block', 'language-markdown'],
+ attributes: {
+ 'data-sse-reference-definition': true,
+ },
+ },
+ { type: 'openTag', tagName: 'code' },
+ {
+ type: 'text',
+ content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"',
+ },
+ { type: 'closeTag', tagName: 'code' },
+ { type: 'closeTag', tagName: 'pre' },
+ ]);
+ });
+
+ it('skips the reference definition node children from rendering', () => {
+ expect(context.skipChildren).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js
deleted file mode 100644
index 7d427108ba6..00000000000
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list';
-import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
-
-import { buildMockTextNode } from './mock_data';
-
-const buildMockListNode = literal => {
- return {
- firstChild: {
- firstChild: {
- firstChild: buildMockTextNode(literal),
- type: 'paragraph',
- },
- type: 'item',
- },
- type: 'list',
- };
-};
-
-const normalListNode = buildMockListNode('Just another bullet point');
-const kramdownListNode = buildMockListNode('TOC');
-
-describe('Render Kramdown List renderer', () => {
- describe('canRender', () => {
- it('should return true when the argument is a special kramdown TOC ordered/unordered list', () => {
- expect(renderer.canRender(kramdownListNode)).toBe(true);
- });
-
- it('should return false when the argument is a normal ordered/unordered list', () => {
- expect(renderer.canRender(normalListNode)).toBe(false);
- });
- });
-
- describe('render', () => {
- it('should delegate rendering to the renderUneditableBranch util', () => {
- expect(renderer.render).toBe(renderUneditableBranch);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js
deleted file mode 100644
index 1d2d152ffc3..00000000000
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text';
-import { renderUneditableLeaf } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
-
-import { buildMockTextNode, normalTextNode } from './mock_data';
-
-const kramdownTextNode = buildMockTextNode('{:toc}');
-
-describe('Render Kramdown Text renderer', () => {
- describe('canRender', () => {
- it('should return true when the argument `literal` has kramdown syntax', () => {
- expect(renderer.canRender(kramdownTextNode)).toBe(true);
- });
-
- it('should return false when the argument `literal` lacks kramdown syntax', () => {
- expect(renderer.canRender(normalTextNode)).toBe(false);
- });
- });
-
- describe('render', () => {
- it('should delegate rendering to the renderUneditableLeaf util', () => {
- expect(renderer.render).toBe(renderUneditableLeaf);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js
new file mode 100644
index 00000000000..c1ab700535b
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js
@@ -0,0 +1,12 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_list_item';
+import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
+
+describe('rich_content_editor/renderers/render_list_item', () => {
+ it('canRender delegates to renderUtils.willAlwaysRender', () => {
+ expect(renderer.canRender).toBe(renderUtils.willAlwaysRender);
+ });
+
+ it('render delegates to renderUtils.renderWithAttributeDefinitions', () => {
+ expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js
index 92435b3e4e3..774f830f421 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js
@@ -1,6 +1,8 @@
import {
renderUneditableLeaf,
renderUneditableBranch,
+ renderWithAttributeDefinitions,
+ willAlwaysRender,
} from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
import {
@@ -8,9 +10,9 @@ import {
buildUneditableOpenTokens,
} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
-import { originToken, uneditableCloseToken } from './mock_data';
+import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data';
-describe('Render utils', () => {
+describe('rich_content_editor/renderers/render_utils', () => {
describe('renderUneditableLeaf', () => {
it('should return uneditable block tokens around an origin token', () => {
const context = { origin: jest.fn().mockReturnValueOnce(originToken) };
@@ -41,4 +43,68 @@ describe('Render utils', () => {
expect(result).toStrictEqual(uneditableCloseToken);
});
});
+
+ describe('willAlwaysRender', () => {
+ it('always returns true', () => {
+ expect(willAlwaysRender()).toBe(true);
+ });
+ });
+
+ describe('renderWithAttributeDefinitions', () => {
+ let openTagToken;
+ let closeTagToken;
+ let node;
+ const attributes = {
+ 'data-attribute-definition': attributeDefinition,
+ };
+
+ beforeEach(() => {
+ openTagToken = { type: 'openTag' };
+ closeTagToken = { type: 'closeTag' };
+ node = {
+ next: {
+ firstChild: {
+ literal: attributeDefinition,
+ },
+ },
+ };
+ });
+
+ describe('when token type is openTag', () => {
+ it('attaches attributes when attributes exist in the node’s next sibling', () => {
+ const context = { origin: () => openTagToken };
+
+ expect(renderWithAttributeDefinitions(node, context)).toEqual({
+ ...openTagToken,
+ attributes,
+ });
+ });
+
+ it('attaches attributes when attributes exist in the node’s children', () => {
+ const context = { origin: () => openTagToken };
+ node = {
+ firstChild: {
+ firstChild: {
+ next: {
+ next: {
+ literal: attributeDefinition,
+ },
+ },
+ },
+ },
+ };
+
+ expect(renderWithAttributeDefinitions(node, context)).toEqual({
+ ...openTagToken,
+ attributes,
+ });
+ });
+ });
+
+ it('does not attach attributes when token type is "closeTag"', () => {
+ const context = { origin: () => closeTagToken };
+
+ expect(renderWithAttributeDefinitions({}, context)).toBe(closeTagToken);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
index edec3b138b3..c2091a681f2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
@@ -14,6 +14,7 @@ const createComponent = headerTitle => {
};
describe('DropdownCreateLabelComponent', () => {
+ const colorsCount = Object.keys(mockSuggestedColors).length;
let vm;
beforeEach(() => {
@@ -27,7 +28,7 @@ describe('DropdownCreateLabelComponent', () => {
describe('created', () => {
it('initializes `suggestedColors` prop on component from `gon.suggested_color_labels` object', () => {
- expect(vm.suggestedColors.length).toBe(mockSuggestedColors.length);
+ expect(vm.suggestedColors.length).toBe(colorsCount);
});
});
@@ -37,12 +38,10 @@ describe('DropdownCreateLabelComponent', () => {
});
it('renders `Go back` button on component header', () => {
- const backButtonEl = vm.$el.querySelector(
- '.dropdown-title button.dropdown-title-button.dropdown-menu-back',
- );
+ const backButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-back');
expect(backButtonEl).not.toBe(null);
- expect(backButtonEl.querySelector('.fa-arrow-left')).not.toBe(null);
+ expect(backButtonEl.querySelector('[data-testid="arrow-left-icon"]')).not.toBe(null);
});
it('renders component header element as `Create new label` when `headerTitle` prop is not provided', () => {
@@ -61,12 +60,9 @@ describe('DropdownCreateLabelComponent', () => {
});
it('renders `Close` button on component header', () => {
- const closeButtonEl = vm.$el.querySelector(
- '.dropdown-title button.dropdown-title-button.dropdown-menu-close',
- );
+ const closeButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-close');
expect(closeButtonEl).not.toBe(null);
- expect(closeButtonEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBe(null);
});
it('renders `Name new label` input element', () => {
@@ -78,11 +74,11 @@ describe('DropdownCreateLabelComponent', () => {
const colorsListContainerEl = vm.$el.querySelector('.suggest-colors.suggest-colors-dropdown');
expect(colorsListContainerEl).not.toBe(null);
- expect(colorsListContainerEl.querySelectorAll('a').length).toBe(mockSuggestedColors.length);
+ expect(colorsListContainerEl.querySelectorAll('a').length).toBe(colorsCount);
const colorItemEl = colorsListContainerEl.querySelectorAll('a')[0];
- expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0]);
+ expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0].colorCode);
expect(colorItemEl.getAttribute('style')).toBe('background-color: rgb(0, 51, 204);');
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js
index 2e721d75b40..0b9a7262e41 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js
@@ -33,7 +33,7 @@ describe('DropdownHeaderComponent', () => {
);
expect(closeBtnEl).not.toBeNull();
- expect(closeBtnEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBeNull();
+ expect(closeBtnEl.querySelector('.dropdown-menu-close-icon')).not.toBeNull();
});
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
index e09f0006359..7847e0ee71d 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
@@ -87,7 +87,7 @@ describe('DropdownValueCollapsedComponent', () => {
});
it('renders tags icon element', () => {
- expect(vm.$el.querySelector('.fa-tags')).not.toBeNull();
+ expect(vm.$el.querySelector('[data-testid="labels-icon"]')).not.toBeNull();
});
it('renders labels count', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js
index 6564c012e67..648ba84fe8f 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js
@@ -15,29 +15,29 @@ export const mockLabels = [
},
];
-export const mockSuggestedColors = [
- '#0033CC',
- '#428BCA',
- '#44AD8E',
- '#A8D695',
- '#5CB85C',
- '#69D100',
- '#004E00',
- '#34495E',
- '#7F8C8D',
- '#A295D6',
- '#5843AD',
- '#8E44AD',
- '#FFECDB',
- '#AD4363',
- '#D10069',
- '#CC0033',
- '#FF0000',
- '#D9534F',
- '#D1D100',
- '#F0AD4E',
- '#AD8D43',
-];
+export const mockSuggestedColors = {
+ '#0033CC': 'UA blue',
+ '#428BCA': 'Moderate blue',
+ '#44AD8E': 'Lime green',
+ '#A8D695': 'Feijoa',
+ '#5CB85C': 'Slightly desaturated green',
+ '#69D100': 'Bright green',
+ '#004E00': 'Very dark lime green',
+ '#34495E': 'Very dark desaturated blue',
+ '#7F8C8D': 'Dark grayish cyan',
+ '#A295D6': 'Slightly desaturated blue',
+ '#5843AD': 'Dark moderate blue',
+ '#8E44AD': 'Dark moderate violet',
+ '#FFECDB': 'Very pale orange',
+ '#AD4363': 'Dark moderate pink',
+ '#D10069': 'Strong pink',
+ '#CC0033': 'Strong red',
+ '#FF0000': 'Pure red',
+ '#D9534F': 'Soft red',
+ '#D1D100': 'Strong yellow',
+ '#F0AD4E': 'Soft orange',
+ '#AD8D43': 'Dark moderate orange',
+};
export const mockConfig = {
showCreate: true,
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
index cb758797c63..951f706421f 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
@@ -62,7 +62,7 @@ describe('DropdownButton', () => {
describe('template', () => {
it('renders component container element', () => {
- expect(wrapper.is('gl-button-stub')).toBe(true);
+ expect(wrapper.find(GlButton).element).toBe(wrapper.element);
});
it('renders default button text element', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
index a1e0db4d29e..8c17a974b39 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
@@ -65,6 +65,33 @@ describe('LabelsSelectRoot', () => {
]),
);
});
+
+ it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => {
+ wrapper = createComponent({
+ ...mockConfig,
+ variant: 'embedded',
+ });
+
+ jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
+
+ wrapper.vm.handleVuexActionDispatch(
+ { type: 'toggleDropdownContents' },
+ {
+ showDropdownButton: false,
+ showDropdownContents: false,
+ labels: [{ id: 1 }, { id: 2, set: true }],
+ },
+ );
+
+ expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ {
+ id: 2,
+ set: true,
+ },
+ ]),
+ );
+ });
});
describe('handleDropdownClose', () => {
@@ -123,11 +150,10 @@ describe('LabelsSelectRoot', () => {
expect(wrapper.find(DropdownTitle).exists()).toBe(true);
});
- it('renders `dropdown-value` component with slot when `showDropdownButton` prop is `false`', () => {
+ it('renders `dropdown-value` component', () => {
const wrapperDropdownValue = createComponent(mockConfig, {
default: 'None',
});
- wrapperDropdownValue.vm.$store.state.showDropdownButton = false;
return wrapperDropdownValue.vm.$nextTick(() => {
const valueComp = wrapperDropdownValue.find(DropdownValue);
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
index c742220ba8a..bfb8e263d81 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
@@ -259,6 +259,21 @@ describe('LabelsSelect Actions', () => {
});
});
+ describe('replaceSelectedLabels', () => {
+ it('replaces `state.selectedLabels`', done => {
+ const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ testAction(
+ actions.replaceSelectedLabels,
+ selectedLabels,
+ state,
+ [{ type: types.REPLACE_SELECTED_LABELS, payload: selectedLabels }],
+ [],
+ done,
+ );
+ });
+ });
+
describe('updateSelectedLabels', () => {
it('updates `state.labels` based on provided `labels` param', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
index 8081806e314..3414eec8a63 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
@@ -152,6 +152,19 @@ describe('LabelsSelect Mutations', () => {
});
});
+ describe(`${types.REPLACE_SELECTED_LABELS}`, () => {
+ it('replaces `state.selectedLabels`', () => {
+ const state = {
+ selectedLabels: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }],
+ };
+ const newSelectedLabels = [{ id: 2 }, { id: 5 }];
+
+ mutations[types.REPLACE_SELECTED_LABELS](state, newSelectedLabels);
+
+ expect(state.selectedLabels).toEqual(newSelectedLabels);
+ });
+ });
+
describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
diff --git a/spec/frontend/vue_shared/components/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js
index ef3ae088eec..058dfcdbde2 100644
--- a/spec/frontend/vue_shared/components/table_pagination_spec.js
+++ b/spec/frontend/vue_shared/components/table_pagination_spec.js
@@ -34,7 +34,7 @@ describe('Pagination component', () => {
change: spy,
});
- expect(wrapper.isEmpty()).toBe(true);
+ expect(wrapper.html()).toBe('');
});
it('renders if there is a next page', () => {
@@ -50,7 +50,7 @@ describe('Pagination component', () => {
change: spy,
});
- expect(wrapper.isEmpty()).toBe(false);
+ expect(wrapper.find(GlPagination).exists()).toBe(true);
});
it('renders if there is a prev page', () => {
@@ -66,7 +66,7 @@ describe('Pagination component', () => {
change: spy,
});
- expect(wrapper.isEmpty()).toBe(false);
+ expect(wrapper.find(GlPagination).exists()).toBe(true);
});
});
diff --git a/spec/frontend/vue_shared/components/todo_button_spec.js b/spec/frontend/vue_shared/components/todo_button_spec.js
new file mode 100644
index 00000000000..482b5de11f6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/todo_button_spec.js
@@ -0,0 +1,48 @@
+import { shallowMount, mount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
+import TodoButton from '~/vue_shared/components/todo_button.vue';
+
+describe('Todo Button', () => {
+ let wrapper;
+
+ const createComponent = (props = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(TodoButton, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders GlButton', () => {
+ createComponent();
+
+ expect(wrapper.find(GlButton).exists()).toBe(true);
+ });
+
+ it('emits click event when clicked', () => {
+ createComponent({}, mount);
+ wrapper.find(GlButton).trigger('click');
+
+ expect(wrapper.emitted().click).toBeTruthy();
+ });
+
+ it.each`
+ label | isTodo
+ ${'Mark as done'} | ${true}
+ ${'Add a To-Do'} | ${false}
+ `('sets correct label when isTodo is $isTodo', ({ label, isTodo }) => {
+ createComponent({ isTodo });
+
+ expect(wrapper.find(GlButton).text()).toBe(label);
+ });
+
+ it('binds additional props to GlButton', () => {
+ createComponent({ loading: true });
+
+ expect(wrapper.find(GlButton).props('loading')).toBe(true);
+ });
+});
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 902e83da7be..84e7a6a162e 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
@@ -38,10 +38,6 @@ describe('User Avatar Link Component', () => {
wrapper = null;
});
- it('should return a defined Vue component', () => {
- expect(wrapper.isVueInstance()).toBe(true);
- });
-
it('should have user-avatar-image registered as child component', () => {
expect(wrapper.vm.$options.components.userAvatarImage).toBeDefined();
});
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 a4ff6ac0c16..b43bb6b10e0 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,7 +1,6 @@
-import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue';
-import Icon from '~/vue_shared/components/icon.vue';
const DEFAULT_PROPS = {
user: {
@@ -74,7 +73,7 @@ describe('User Popover Component', () => {
});
it('shows icon for location', () => {
- const iconEl = wrapper.find(Icon);
+ const iconEl = wrapper.find(GlIcon);
expect(iconEl.props('name')).toEqual('location');
});
@@ -139,9 +138,9 @@ describe('User Popover Component', () => {
createWrapper({ user });
- expect(wrapper.findAll(Icon).filter(icon => icon.props('name') === 'profile').length).toEqual(
- 1,
- );
+ expect(
+ wrapper.findAll(GlIcon).filter(icon => icon.props('name') === 'profile').length,
+ ).toEqual(1);
});
it('shows icon for work information', () => {
@@ -152,7 +151,9 @@ describe('User Popover Component', () => {
createWrapper({ user });
- expect(wrapper.findAll(Icon).filter(icon => icon.props('name') === 'work').length).toEqual(1);
+ expect(wrapper.findAll(GlIcon).filter(icon => icon.props('name') === 'work').length).toEqual(
+ 1,
+ );
});
});
diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js
new file mode 100644
index 00000000000..57f511903d9
--- /dev/null
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -0,0 +1,106 @@
+import { shallowMount } from '@vue/test-utils';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
+import ActionsButton from '~/vue_shared/components/actions_button.vue';
+
+const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/master/-/';
+const TEST_GITPOD_URL = 'https://gitpod.test/';
+
+const ACTION_WEB_IDE = {
+ href: TEST_WEB_IDE_URL,
+ key: 'webide',
+ secondaryText: 'Quickly and easily edit multiple files in your project.',
+ tooltip: '',
+ text: 'Web IDE',
+ attrs: {
+ 'data-qa-selector': 'web_ide_button',
+ },
+};
+const ACTION_WEB_IDE_FORK = {
+ ...ACTION_WEB_IDE,
+ href: '#modal-confirm-fork',
+ handle: expect.any(Function),
+};
+const ACTION_GITPOD = {
+ href: TEST_GITPOD_URL,
+ key: 'gitpod',
+ secondaryText: 'Launch a ready-to-code development environment for your project.',
+ tooltip: 'Launch a ready-to-code development environment for your project.',
+ text: 'Gitpod',
+ attrs: {
+ 'data-qa-selector': 'gitpod_button',
+ },
+};
+const ACTION_GITPOD_ENABLE = {
+ ...ACTION_GITPOD,
+ href: '#modal-enable-gitpod',
+ handle: expect.any(Function),
+};
+
+describe('Web IDE link component', () => {
+ let wrapper;
+
+ function createComponent(props) {
+ wrapper = shallowMount(WebIdeLink, {
+ propsData: {
+ webIdeUrl: TEST_WEB_IDE_URL,
+ gitpodUrl: TEST_GITPOD_URL,
+ ...props,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findActionsButton = () => wrapper.find(ActionsButton);
+ const findLocalStorageSync = () => wrapper.find(LocalStorageSync);
+
+ it.each`
+ props | expectedActions
+ ${{}} | ${[ACTION_WEB_IDE]}
+ ${{ needsToFork: true }} | ${[ACTION_WEB_IDE_FORK]}
+ ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }} | ${[ACTION_GITPOD]}
+ ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_GITPOD_ENABLE]}
+ ${{ showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_WEB_IDE, ACTION_GITPOD_ENABLE]}
+ `('renders actions with props=$props', ({ props, expectedActions }) => {
+ createComponent(props);
+
+ expect(findActionsButton().props('actions')).toEqual(expectedActions);
+ });
+
+ describe('with multiple actions', () => {
+ beforeEach(() => {
+ createComponent({ showWebIdeButton: true, showGitpodButton: true, gitpodEnabled: true });
+ });
+
+ it('selected Web IDE by default', () => {
+ expect(findActionsButton().props()).toMatchObject({
+ actions: [ACTION_WEB_IDE, ACTION_GITPOD],
+ selectedKey: ACTION_WEB_IDE.key,
+ });
+ });
+
+ it('should set selection with local storage value', async () => {
+ expect(findActionsButton().props('selectedKey')).toBe(ACTION_WEB_IDE.key);
+
+ findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key);
+
+ await wrapper.vm.$nextTick();
+
+ expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key);
+ });
+
+ it('should update local storage when selection changes', async () => {
+ expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key);
+
+ findActionsButton().vm.$emit('select', ACTION_GITPOD.key);
+
+ await wrapper.vm.$nextTick();
+
+ expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key);
+ expect(findLocalStorageSync().props('value')).toBe(ACTION_GITPOD.key);
+ });
+ });
+});
diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js
index a349aad9f1c..59d05f68fdd 100644
--- a/spec/frontend/whats_new/components/app_spec.js
+++ b/spec/frontend/whats_new/components/app_spec.js
@@ -11,9 +11,11 @@ describe('App', () => {
let store;
let actions;
let state;
+ let propsData = { features: '[ {"title":"Whats New Drawer"} ]' };
- beforeEach(() => {
+ const buildWrapper = () => {
actions = {
+ openDrawer: jest.fn(),
closeDrawer: jest.fn(),
};
@@ -29,7 +31,12 @@ describe('App', () => {
wrapper = mount(App, {
localVue,
store,
+ propsData,
});
+ };
+
+ beforeEach(() => {
+ buildWrapper();
});
afterEach(() => {
@@ -42,6 +49,10 @@ describe('App', () => {
expect(getDrawer().exists()).toBe(true);
});
+ it('dispatches openDrawer when mounted', () => {
+ expect(actions.openDrawer).toHaveBeenCalled();
+ });
+
it('dispatches closeDrawer when clicking close', () => {
getDrawer().vm.$emit('close');
expect(actions.closeDrawer).toHaveBeenCalled();
@@ -54,4 +65,15 @@ describe('App', () => {
expect(getDrawer().props('open')).toBe(openState);
});
+
+ it('renders features when provided as props', () => {
+ expect(wrapper.find('h5').text()).toBe('Whats New Drawer');
+ });
+
+ it('handles bad json argument gracefully', () => {
+ propsData = { features: 'this is not json' };
+ buildWrapper();
+
+ expect(getDrawer().exists()).toBe(true);
+ });
});
diff --git a/spec/frontend/whats_new/components/trigger_spec.js b/spec/frontend/whats_new/components/trigger_spec.js
deleted file mode 100644
index 7961957e077..00000000000
--- a/spec/frontend/whats_new/components/trigger_spec.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { createLocalVue, mount } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { GlButton } from '@gitlab/ui';
-import Trigger from '~/whats_new/components/trigger.vue';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('Trigger', () => {
- let wrapper;
- let store;
- let actions;
- let state;
-
- beforeEach(() => {
- actions = {
- openDrawer: jest.fn(),
- };
-
- state = {
- open: true,
- };
-
- store = new Vuex.Store({
- actions,
- state,
- });
-
- wrapper = mount(Trigger, {
- localVue,
- store,
- });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('dispatches openDrawer when clicking close', () => {
- wrapper.find(GlButton).vm.$emit('click');
- expect(actions.openDrawer).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend_integration/test_helpers/factories/commit.js b/spec/frontend_integration/test_helpers/factories/commit.js
index 1ee82e74ffe..09bb5fd589b 100644
--- a/spec/frontend_integration/test_helpers/factories/commit.js
+++ b/spec/frontend_integration/test_helpers/factories/commit.js
@@ -2,7 +2,6 @@ import { withValues } from '../utils/obj';
import { getCommit } from '../fixtures';
import { createCommitId } from './commit_id';
-// eslint-disable-next-line import/prefer-default-export
export const createNewCommit = ({ id = createCommitId(), message }, orig = getCommit()) => {
return withValues(orig, {
id,
diff --git a/spec/frontend_integration/test_helpers/mock_server/graphql.js b/spec/frontend_integration/test_helpers/mock_server/graphql.js
index 6dcc4798378..654c373e5a6 100644
--- a/spec/frontend_integration/test_helpers/mock_server/graphql.js
+++ b/spec/frontend_integration/test_helpers/mock_server/graphql.js
@@ -16,6 +16,5 @@ const graphqlResolvers = {
},
};
-// eslint-disable-next-line import/prefer-default-export
export const graphqlQuery = (query, variables, schema) =>
graphql(graphqlSchema, query, graphqlResolvers, schema, variables);
diff --git a/spec/frontend_integration/test_helpers/utils/overclock_timers.js b/spec/frontend_integration/test_helpers/utils/overclock_timers.js
index 046c7f8e527..7df8af30a61 100644
--- a/spec/frontend_integration/test_helpers/utils/overclock_timers.js
+++ b/spec/frontend_integration/test_helpers/utils/overclock_timers.js
@@ -31,7 +31,6 @@
*
* @param {Number} boost
*/
-// eslint-disable-next-line import/prefer-default-export
export const useOverclockTimers = (boost = 50) => {
if (boost <= 0) {
throw new Error(`[overclock_timers] boost (${boost}) cannot be <= 0`);
diff --git a/spec/graphql/gitlab_schema_spec.rb b/spec/graphql/gitlab_schema_spec.rb
index e52f60a2c02..4db12643069 100644
--- a/spec/graphql/gitlab_schema_spec.rb
+++ b/spec/graphql/gitlab_schema_spec.rb
@@ -237,7 +237,7 @@ RSpec.describe GitlabSchema do
it 'raises an error' do
expect { described_class.parse_gid(global_id) }
- .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id.")
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab ID.")
end
end
@@ -256,7 +256,7 @@ RSpec.describe GitlabSchema do
it 'rejects an unknown type' do
expect { described_class.parse_gid(global_id, expected_type: TestTwo) }
- .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid id for TestTwo.")
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid ID for TestTwo.")
end
end
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 f8b61c5064a..31abbabe385 100644
--- a/spec/graphql/mutations/alert_management/alerts/set_assignees_spec.rb
+++ b/spec/graphql/mutations/alert_management/alerts/set_assignees_spec.rb
@@ -55,6 +55,7 @@ RSpec.describe Mutations::AlertManagement::Alerts::SetAssignees do
context 'when operation mode is not specified' do
it_behaves_like 'successful resolution'
+ it_behaves_like 'an incident management tracked event', :incident_management_alert_assigned
end
context 'when user does not have permission to update alerts' do
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 11ee40a4c7e..a10c3725ba2 100644
--- a/spec/graphql/mutations/alert_management/alerts/todo/create_spec.rb
+++ b/spec/graphql/mutations/alert_management/alerts/todo/create_spec.rb
@@ -16,6 +16,8 @@ RSpec.describe Mutations::AlertManagement::Alerts::Todo::Create do
describe '#resolve' do
subject(:resolve) { mutation.resolve(args) }
+ it_behaves_like 'an incident management tracked event', :incident_management_alert_todo
+
context 'when user does not have permissions' do
let(:current_user) { nil }
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 fa5a84b4fcc..e6a7434d579 100644
--- a/spec/graphql/mutations/alert_management/create_alert_issue_spec.rb
+++ b/spec/graphql/mutations/alert_management/create_alert_issue_spec.rb
@@ -26,6 +26,8 @@ RSpec.describe Mutations::AlertManagement::CreateAlertIssue do
errors: []
)
end
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_created
end
context 'when CreateAlertIssue responds with an error' do
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 a224b564de9..ab98088ebcd 100644
--- a/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
+++ b/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
@@ -30,6 +30,10 @@ RSpec.describe Mutations::AlertManagement::UpdateAlertStatus do
)
end
+ it_behaves_like 'an incident management tracked event', :incident_management_alert_status_changed do
+ let(:user) { current_user }
+ end
+
context 'error occurs when updating' do
it 'returns the alert with errors' do
# Stub an error on the alert
diff --git a/spec/graphql/mutations/boards/lists/create_spec.rb b/spec/graphql/mutations/boards/lists/create_spec.rb
index 1a881ac81e8..b1fe9911c7b 100644
--- a/spec/graphql/mutations/boards/lists/create_spec.rb
+++ b/spec/graphql/mutations/boards/lists/create_spec.rb
@@ -24,14 +24,12 @@ RSpec.describe Mutations::Boards::Lists::Create do
describe '#ready?' do
it 'raises an error if required arguments are missing' do
expect { mutation.ready?({ board_id: 'some id' }) }
- .to raise_error(Gitlab::Graphql::Errors::ArgumentError,
- 'one and only one of backlog or labelId is required')
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError, /one and only one of/)
end
it 'raises an error if too many required arguments are specified' do
expect { mutation.ready?({ board_id: 'some id', backlog: true, label_id: 'some label' }) }
- .to raise_error(Gitlab::Graphql::Errors::ArgumentError,
- 'one and only one of backlog or labelId is required')
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError, /one and only one of/)
end
end
@@ -66,6 +64,15 @@ RSpec.describe Mutations::Boards::Lists::Create do
expect(new_list.title).to eq dev_label.title
expect(new_list.position).to eq 0
end
+
+ context 'when label not found' do
+ let(:list_create_params) { { label_id: "gid://gitlab/Label/#{non_existing_record_id}" } }
+
+ it 'raises an error' do
+ expect { subject }
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'Label not found!')
+ end
+ end
end
end
diff --git a/spec/graphql/mutations/discussions/toggle_resolve_spec.rb b/spec/graphql/mutations/discussions/toggle_resolve_spec.rb
index 9ac4d6ab165..d779a2227c1 100644
--- a/spec/graphql/mutations/discussions/toggle_resolve_spec.rb
+++ b/spec/graphql/mutations/discussions/toggle_resolve_spec.rb
@@ -51,7 +51,7 @@ RSpec.describe Mutations::Discussions::ToggleResolve do
it 'raises an error' do
expect { subject }.to raise_error(
Gitlab::Graphql::Errors::ArgumentError,
- "#{discussion.to_global_id} is not a valid id for Discussion."
+ "#{discussion.to_global_id} is not a valid ID for Discussion."
)
end
end
diff --git a/spec/graphql/mutations/issues/set_severity_spec.rb b/spec/graphql/mutations/issues/set_severity_spec.rb
new file mode 100644
index 00000000000..ed73d3b777e
--- /dev/null
+++ b/spec/graphql/mutations/issues/set_severity_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Issues::SetSeverity do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:issue) { create(:incident) }
+ let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+
+ specify { expect(described_class).to require_graphql_authorizations(:update_issue) }
+
+ describe '#resolve' do
+ let(:severity) { 'CRITICAL' }
+ let(:mutated_incident) { subject[:issue] }
+
+ subject(:resolve) { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, severity: severity) }
+
+ context 'when the user cannot update the issue' do
+ it 'raises an error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when the user can update the issue' do
+ before do
+ issue.project.add_developer(user)
+ end
+
+ context 'when issue type is incident' do
+ context 'when severity has a correct value' do
+ it 'updates severity' do
+ expect(resolve[:issue].severity).to eq('critical')
+ end
+
+ it 'returns no errors' do
+ expect(resolve[:errors]).to be_empty
+ end
+ end
+
+ context 'when severity has an unsuported value' do
+ let(:severity) { 'unsupported-severity' }
+
+ it 'sets severity to default' do
+ expect(resolve[:issue].severity).to eq(IssuableSeverity::DEFAULT)
+ end
+
+ it 'returns no errorsr' do
+ expect(resolve[:errors]).to be_empty
+ end
+ end
+ end
+
+ context 'when issue type is not incident' do
+ let!(:issue) { create(:issue) }
+
+ it 'does not updates the issue' do
+ expect { resolve }.not_to change { issue.updated_at }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb b/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb
new file mode 100644
index 00000000000..76854be2daa
--- /dev/null
+++ b/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsResolver do
+ include GraphqlHelpers
+
+ describe '#resolve' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:admin_user) { create(:user, :admin) }
+
+ let_it_be(:project_measurement_new) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) }
+ let_it_be(:project_measurement_old) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) }
+
+ subject { resolve_measurements({ identifier: 'projects' }, { current_user: current_user }) }
+
+ context 'when requesting project count measurements' do
+ context 'as an admin user' do
+ let(:current_user) { admin_user }
+
+ it 'returns the records, latest first' do
+ expect(subject).to eq([project_measurement_new, project_measurement_old])
+ end
+ end
+
+ context 'as a non-admin user' do
+ let(:current_user) { user }
+
+ it 'raises ResourceNotAvailable error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'as an unauthenticated user' do
+ let(:current_user) { nil }
+
+ it 'raises ResourceNotAvailable error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+ end
+
+ def resolve_measurements(args = {}, context = {})
+ resolve(described_class, args: args, ctx: context)
+ end
+end
diff --git a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb
index e23a37b3d69..4ccf194522f 100644
--- a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb
@@ -11,41 +11,59 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
let_it_be(:group) { create(:group, :private) }
shared_examples_for 'group and project board list issues resolver' do
- let!(:board) { create(:board, resource_parent: board_parent) }
-
before do
board_parent.add_developer(user)
end
# auth is handled by the parent object
context 'when authorized' do
- let!(:list) { create(:list, board: board, label: label) }
+ let!(:issue1) { create(:issue, project: project, labels: [label], relative_position: 10) }
+ let!(:issue2) { create(:issue, project: project, labels: [label, label2], relative_position: 12) }
+ let!(:issue3) { create(:issue, project: project, labels: [label, label3], relative_position: 10) }
it 'returns the issues in the correct order' do
- issue1 = create(:issue, project: project, labels: [label], relative_position: 10)
- issue2 = create(:issue, project: project, labels: [label], relative_position: 12)
- issue3 = create(:issue, project: project, labels: [label], relative_position: 10)
-
# by relative_position and then ID
issues = resolve_board_list_issues.items
expect(issues.map(&:id)).to eq [issue3.id, issue1.id, issue2.id]
end
+
+ it 'finds only issues matching filters' do
+ result = resolve_board_list_issues(args: { filters: { label_name: label.title, not: { label_name: label2.title } } }).items
+
+ expect(result).to match_array([issue1, issue3])
+ end
+
+ it 'finds only issues matching search param' do
+ result = resolve_board_list_issues(args: { filters: { search: issue1.title } }).items
+
+ expect(result).to match_array([issue1])
+ end
end
end
describe '#resolve' do
context 'when project boards' do
+ let_it_be(:label) { create(:label, project: user_project) }
+ let_it_be(:label2) { create(:label, project: user_project) }
+ let_it_be(:label3) { create(:label, project: user_project) }
+ let_it_be(:board) { create(:board, resource_parent: user_project) }
+ let_it_be(:list) { create(:list, board: board, label: label) }
+
let(:board_parent) { user_project }
- let!(:label) { create(:label, project: project, name: 'project label') }
let(:project) { user_project }
it_behaves_like 'group and project board list issues resolver'
end
context 'when group boards' do
+ let_it_be(:label) { create(:group_label, group: group) }
+ let_it_be(:label2) { create(:group_label, group: group) }
+ let_it_be(:label3) { create(:group_label, group: group) }
+ let_it_be(:board) { create(:board, resource_parent: group) }
+ let_it_be(:list) { create(:list, board: board, label: label) }
+
let(:board_parent) { group }
- let!(:label) { create(:group_label, group: group, name: 'group label') }
let!(:project) { create(:project, :private, group: group) }
it_behaves_like 'group and project board list issues resolver'
diff --git a/spec/graphql/resolvers/group_members_resolver_spec.rb b/spec/graphql/resolvers/group_members_resolver_spec.rb
new file mode 100644
index 00000000000..bbfea575492
--- /dev/null
+++ b/spec/graphql/resolvers/group_members_resolver_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::GroupMembersResolver do
+ include GraphqlHelpers
+
+ it_behaves_like 'querying members with a group' do
+ let_it_be(:resource_member) { create(:group_member, user: user_1, group: group_1) }
+ let_it_be(:resource) { group_1 }
+ end
+end
diff --git a/spec/graphql/resolvers/issue_status_counts_resolver_spec.rb b/spec/graphql/resolvers/issue_status_counts_resolver_spec.rb
index 69e940ee6ca..16eb190efc6 100644
--- a/spec/graphql/resolvers/issue_status_counts_resolver_spec.rb
+++ b/spec/graphql/resolvers/issue_status_counts_resolver_spec.rb
@@ -41,6 +41,12 @@ RSpec.describe Resolvers::IssueStatusCountsResolver do
it_behaves_like 'returns expected results'
+ context 'project used as parent' do
+ let(:parent) { project }
+
+ it_behaves_like 'returns expected results'
+ end
+
context 'group used as parent' do
let(:parent) { project.group }
diff --git a/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb b/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb
index 2fe3e86ec14..ae3097c1d9e 100644
--- a/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb
+++ b/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb
@@ -29,4 +29,11 @@ RSpec.describe Resolvers::MergeRequestPipelinesResolver do
it 'resolves only MRs for the passed merge request' do
expect(resolve_pipelines).to contain_exactly(pipeline)
end
+
+ describe 'with archived project' do
+ let(:archived_project) { create(:project, :archived) }
+ let(:merge_request) { create(:merge_request, source_project: archived_project) }
+
+ it { expect(resolve_pipelines).not_to contain_exactly(pipeline) }
+ end
end
diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
index e939edae779..aecffc487aa 100644
--- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb
+++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
@@ -6,16 +6,20 @@ RSpec.describe Resolvers::MergeRequestsResolver do
include GraphqlHelpers
let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:current_user) { create(:user) }
+ let_it_be(:other_user) { create(:user) }
let_it_be(:common_attrs) { { author: current_user, source_project: project, target_project: project } }
let_it_be(:merge_request_1) { create(:merge_request, :simple, **common_attrs) }
let_it_be(:merge_request_2) { create(:merge_request, :rebased, **common_attrs) }
let_it_be(:merge_request_3) { create(:merge_request, :unique_branches, **common_attrs) }
let_it_be(:merge_request_4) { create(:merge_request, :unique_branches, :locked, **common_attrs) }
let_it_be(:merge_request_5) { create(:merge_request, :simple, :locked, **common_attrs) }
- let_it_be(:merge_request_6) { create(:labeled_merge_request, :unique_branches, labels: create_list(:label, 2), **common_attrs) }
+ let_it_be(:merge_request_6) { create(:labeled_merge_request, :unique_branches, labels: create_list(:label, 2, project: project), **common_attrs) }
+ let_it_be(:merge_request_with_milestone) { create(:merge_request, :unique_branches, **common_attrs, milestone: milestone) }
let_it_be(:other_project) { create(:project, :repository) }
let_it_be(:other_merge_request) { create(:merge_request, source_project: other_project, target_project: other_project) }
+
let(:iid_1) { merge_request_1.iid }
let(:iid_2) { merge_request_2.iid }
let(:other_iid) { other_merge_request.iid }
@@ -32,7 +36,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
it 'returns all merge requests' do
result = resolve_mr(project, {})
- expect(result).to contain_exactly(merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5, merge_request_6)
+ expect(result).to contain_exactly(merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5, merge_request_6, merge_request_with_milestone)
end
it 'returns only merge requests that the current user can see' do
@@ -179,6 +183,20 @@ RSpec.describe Resolvers::MergeRequestsResolver do
end
end
+ context 'by milestone' do
+ it 'filters merge requests by milestone title' do
+ result = resolve_mr(project, milestone_title: milestone.title)
+
+ expect(result).to eq([merge_request_with_milestone])
+ end
+
+ it 'does not find anything' do
+ result = resolve_mr(project, milestone_title: 'unknown-milestone')
+
+ expect(result).to be_empty
+ end
+ end
+
describe 'combinations' do
it 'requires all filters' do
create(:merge_request, :closed, source_project: project, target_project: project, source_branch: merge_request_4.source_branch)
@@ -188,6 +206,33 @@ RSpec.describe Resolvers::MergeRequestsResolver do
expect(result.compact).to contain_exactly(merge_request_4)
end
end
+
+ describe 'sorting' do
+ context 'when sorting by created' do
+ it 'sorts merge requests ascending' do
+ expect(resolve_mr(project, sort: 'created_asc')).to eq [merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5, merge_request_6, merge_request_with_milestone]
+ end
+
+ it 'sorts merge requests descending' do
+ expect(resolve_mr(project, sort: 'created_desc')).to eq [merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_3, merge_request_2, merge_request_1]
+ end
+ end
+
+ context 'when sorting by merged at' do
+ before do
+ merge_request_1.metrics.update!(merged_at: 10.days.ago)
+ merge_request_3.metrics.update!(merged_at: 5.days.ago)
+ end
+
+ it 'sorts merge requests ascending' do
+ expect(resolve_mr(project, sort: :merged_at_asc)).to eq [merge_request_1, merge_request_3, merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_2]
+ end
+
+ it 'sorts merge requests descending' do
+ expect(resolve_mr(project, sort: :merged_at_desc)).to eq [merge_request_3, merge_request_1, merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_2]
+ end
+ end
+ end
end
def resolve_mr_single(project, iid)
diff --git a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb
index 699269b47e0..4ad8f99219f 100644
--- a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb
+++ b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
end
it 'finds all projects including the subgroups' do
- expect(resolve_projects(include_subgroups: true)).to contain_exactly(project1, project2, nested_project)
+ expect(resolve_projects(include_subgroups: true, sort: nil, search: nil)).to contain_exactly(project1, project2, nested_project)
end
context 'with an user namespace' do
@@ -38,7 +38,52 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
end
it 'finds all projects including the subgroups' do
- expect(resolve_projects(include_subgroups: true)).to contain_exactly(project1, project2)
+ expect(resolve_projects(include_subgroups: true, sort: nil, search: nil)).to contain_exactly(project1, project2)
+ end
+ end
+ end
+
+ context 'search and similarity sorting' do
+ let(:project_1) { create(:project, name: 'Project', path: 'project', namespace: namespace) }
+ let(:project_2) { create(:project, name: 'Test Project', path: 'test-project', namespace: namespace) }
+ let(:project_3) { create(:project, name: 'Test', path: 'test', namespace: namespace) }
+
+ before do
+ project_1.add_developer(current_user)
+ project_2.add_developer(current_user)
+ project_3.add_developer(current_user)
+ end
+
+ it 'returns projects ordered by similarity to the search input' do
+ projects = resolve_projects(include_subgroups: true, sort: :similarity, search: 'test')
+
+ project_names = projects.map { |proj| proj['name'] }
+ expect(project_names.first).to eq('Test')
+ expect(project_names.second).to eq('Test Project')
+ end
+
+ it 'filters out result that do not match the search input' do
+ projects = resolve_projects(include_subgroups: true, sort: :similarity, search: 'test')
+
+ project_names = projects.map { |proj| proj['name'] }
+ expect(project_names).not_to include('Project')
+ end
+
+ context 'when `search` parameter is not given' do
+ it 'returns projects not ordered by similarity' do
+ projects = resolve_projects(include_subgroups: true, sort: :similarity, search: nil)
+
+ project_names = projects.map { |proj| proj['name'] }
+ expect(project_names.first).not_to eq('Test')
+ end
+ end
+
+ context 'when only search term is given' do
+ it 'filters out result that do not match the search input, but does not sort them' do
+ projects = resolve_projects(include_subgroups: true, sort: :nil, search: 'test')
+
+ project_names = projects.map { |proj| proj['name'] }
+ expect(project_names).to contain_exactly('Test', 'Test Project')
end
end
end
@@ -63,7 +108,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
expect(field.to_graphql.complexity.call({}, { include_subgroups: true }, 1)).to eq 24
end
- def resolve_projects(args = { include_subgroups: false }, context = { current_user: current_user })
+ def resolve_projects(args = { include_subgroups: false, sort: nil, search: nil }, context = { current_user: current_user })
resolve(described_class, obj: namespace, args: args, ctx: context)
end
end
diff --git a/spec/graphql/resolvers/project_members_resolver_spec.rb b/spec/graphql/resolvers/project_members_resolver_spec.rb
index 602225cf632..2f4145b3215 100644
--- a/spec/graphql/resolvers/project_members_resolver_spec.rb
+++ b/spec/graphql/resolvers/project_members_resolver_spec.rb
@@ -5,58 +5,9 @@ require 'spec_helper'
RSpec.describe Resolvers::ProjectMembersResolver do
include GraphqlHelpers
- context "with a group" do
- let_it_be(:root_group) { create(:group) }
- let_it_be(:group_1) { create(:group, parent: root_group) }
- let_it_be(:group_2) { create(:group, parent: root_group) }
- let_it_be(:project) { create(:project, :public, group: group_1) }
-
- let_it_be(:user_1) { create(:user, name: 'test user') }
- let_it_be(:user_2) { create(:user, name: 'test user 2') }
- let_it_be(:user_3) { create(:user, name: 'another user 1') }
- let_it_be(:user_4) { create(:user, name: 'another user 2') }
-
- let_it_be(:project_member) { create(:project_member, user: user_1, project: project) }
- let_it_be(:group_1_member) { create(:group_member, user: user_2, group: group_1) }
- let_it_be(:group_2_member) { create(:group_member, user: user_3, group: group_2) }
- let_it_be(:root_group_member) { create(:group_member, user: user_4, group: root_group) }
-
- let(:args) { {} }
-
- subject do
- resolve(described_class, obj: project, args: args, ctx: { context: user_4 })
- end
-
- describe '#resolve' do
- it 'finds all project members' do
- expect(subject).to contain_exactly(project_member, group_1_member, root_group_member)
- end
-
- context 'with search' do
- context 'when the search term matches a user' do
- let(:args) { { search: 'test' } }
-
- it 'searches users by user name' do
- expect(subject).to contain_exactly(project_member, group_1_member)
- end
- end
-
- context 'when the search term does not match any user' do
- let(:args) { { search: 'nothing' } }
-
- it 'is empty' do
- expect(subject).to be_empty
- end
- end
- end
-
- context 'when project is nil' do
- let(:project) { nil }
-
- it 'returns nil' do
- expect(subject).to be_empty
- end
- end
- end
+ it_behaves_like 'querying members with a group' do
+ let_it_be(:project) { create(:project, group: group_1) }
+ let_it_be(:resource_member) { create(:project_member, user: user_1, project: project) }
+ let_it_be(:resource) { project }
end
end
diff --git a/spec/graphql/resolvers/project_merge_requests_resolver_spec.rb b/spec/graphql/resolvers/project_merge_requests_resolver_spec.rb
new file mode 100644
index 00000000000..bfb3ce91d58
--- /dev/null
+++ b/spec/graphql/resolvers/project_merge_requests_resolver_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::ProjectMergeRequestsResolver do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:other_user) { create(:user) }
+
+ let_it_be(:merge_request_with_author_and_assignee) do
+ create(:merge_request,
+ :unique_branches,
+ source_project: project,
+ target_project: project,
+ author: other_user,
+ assignee: other_user)
+ end
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'by assignee' do
+ it 'filters merge requests by assignee username' do
+ result = resolve_mr(project, assignee_username: other_user.username)
+
+ expect(result).to eq([merge_request_with_author_and_assignee])
+ end
+
+ it 'does not find anything' do
+ result = resolve_mr(project, assignee_username: 'unknown-user')
+
+ expect(result).to be_empty
+ end
+ end
+
+ context 'by author' do
+ it 'filters merge requests by author username' do
+ result = resolve_mr(project, author_username: other_user.username)
+
+ expect(result).to eq([merge_request_with_author_and_assignee])
+ end
+
+ it 'does not find anything' do
+ result = resolve_mr(project, author_username: 'unknown-user')
+
+ expect(result).to be_empty
+ end
+ end
+
+ def resolve_mr(project, args, resolver: described_class, user: current_user)
+ resolve(resolver, obj: project, args: args, ctx: { current_user: user })
+ end
+end
diff --git a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb
index fada2f9193c..a6a86c49373 100644
--- a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb
+++ b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb
@@ -34,12 +34,10 @@ RSpec.describe Resolvers::ProjectPipelineResolver do
expect { resolve_pipeline(project, {}) }.to raise_error(ArgumentError)
end
- context 'when the pipeline is not a ci_config_source' do
+ context 'when the pipeline is a dangling pipeline' do
let(:pipeline) do
- config_source_value = Ci::PipelineEnums.non_ci_config_source_values.first
- config_source = Ci::PipelineEnums.config_sources.key(config_source_value)
-
- create(:ci_pipeline, config_source: config_source, project: project)
+ dangling_source = ::Enums::Ci::Pipeline.dangling_sources.each_value.first
+ create(:ci_pipeline, source: dangling_source, project: project)
end
it 'resolves pipeline for the passed iid' do
diff --git a/spec/graphql/resolvers/projects_resolver_spec.rb b/spec/graphql/resolvers/projects_resolver_spec.rb
index db7c9225c84..d22ffeed740 100644
--- a/spec/graphql/resolvers/projects_resolver_spec.rb
+++ b/spec/graphql/resolvers/projects_resolver_spec.rb
@@ -71,6 +71,14 @@ RSpec.describe Resolvers::ProjectsResolver do
is_expected.to contain_exactly(project, private_project)
end
end
+
+ context 'when ids filter is provided' do
+ let(:filters) { { ids: [project.to_global_id.to_s] } }
+
+ it 'returns matching project' do
+ is_expected.to contain_exactly(project)
+ end
+ end
end
end
end
diff --git a/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb b/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb
new file mode 100644
index 00000000000..625fb17bbf8
--- /dev/null
+++ b/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['MeasurementIdentifier'] do
+ specify { expect(described_class.graphql_name).to eq('MeasurementIdentifier') }
+
+ it 'exposes all the existing identifier values' do
+ identifiers = Analytics::InstanceStatistics::Measurement.identifiers.keys.map(&:upcase)
+
+ expect(described_class.values.keys).to match_array(identifiers)
+ end
+end
diff --git a/spec/graphql/types/admin/analytics/instance_statistics/measurement_type_spec.rb b/spec/graphql/types/admin/analytics/instance_statistics/measurement_type_spec.rb
new file mode 100644
index 00000000000..de8143a5466
--- /dev/null
+++ b/spec/graphql/types/admin/analytics/instance_statistics/measurement_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do
+ subject { described_class }
+
+ it { is_expected.to have_graphql_field(:recorded_at) }
+ it { is_expected.to have_graphql_field(:identifier) }
+ it { is_expected.to have_graphql_field(:count) }
+end
diff --git a/spec/graphql/types/base_enum_spec.rb b/spec/graphql/types/base_enum_spec.rb
index 0d0f6346f2d..b7adcf217f6 100644
--- a/spec/graphql/types/base_enum_spec.rb
+++ b/spec/graphql/types/base_enum_spec.rb
@@ -21,4 +21,16 @@ RSpec.describe Types::BaseEnum do
expect(enum.enum).to be_a(HashWithIndifferentAccess)
end
end
+
+ include_examples 'Gitlab-style deprecations' do
+ def subject(args = {})
+ enum = Class.new(described_class) do
+ graphql_name 'TestEnum'
+
+ value 'TEST_VALUE', **args
+ end
+
+ enum.to_graphql.values['TEST_VALUE']
+ end
+ end
end
diff --git a/spec/graphql/types/base_field_spec.rb b/spec/graphql/types/base_field_spec.rb
index 73bb54e7ad0..bcfbd7f2480 100644
--- a/spec/graphql/types/base_field_spec.rb
+++ b/spec/graphql/types/base_field_spec.rb
@@ -167,70 +167,23 @@ RSpec.describe Types::BaseField do
end
end
- describe '`deprecated` property' do
- def test_field(args = {})
+ include_examples 'Gitlab-style deprecations' do
+ def subject(args = {})
base_args = { name: 'test', type: GraphQL::STRING_TYPE, null: true }
described_class.new(**base_args.merge(args))
end
- describe 'validations' do
- it 'raises an informative error if `deprecation_reason` is used' do
- expect { test_field(deprecation_reason: 'foo') }.to raise_error(
- ArgumentError,
- 'Use `deprecated` property instead of `deprecation_reason`. ' \
- 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields'
- )
- end
-
- it 'raises an error if a required property is missing', :aggregate_failures do
- expect { test_field(deprecated: { milestone: '1.10' }) }.to raise_error(
- ArgumentError,
- 'Please provide a `reason` within `deprecated`'
- )
- expect { test_field(deprecated: { reason: 'Deprecation reason' }) }.to raise_error(
- ArgumentError,
- 'Please provide a `milestone` within `deprecated`'
- )
- end
-
- it 'raises an error if milestone is not a String', :aggregate_failures do
- expect { test_field(deprecated: { milestone: 1.10, reason: 'Deprecation reason' }) }.to raise_error(
- ArgumentError,
- '`milestone` must be a `String`'
- )
- end
- end
-
- it 'adds a formatted `deprecated_reason` to the field' do
- field = test_field(deprecated: { milestone: '1.10', reason: 'Deprecation reason' })
-
- expect(field.deprecation_reason).to eq('Deprecation reason. Deprecated in 1.10')
- end
-
- it 'appends to the description if given' do
- field = test_field(
- deprecated: { milestone: '1.10', reason: 'Deprecation reason' },
- description: 'Field description'
- )
-
- expect(field.description).to eq('Field description. Deprecated in 1.10: Deprecation reason')
- end
-
- it 'does not append to the description if it is absent' do
- field = test_field(deprecated: { milestone: '1.10', reason: 'Deprecation reason' })
-
- expect(field.description).to be_nil
- end
-
it 'interacts well with the `feature_flag` property' do
- field = test_field(
+ field = subject(
deprecated: { milestone: '1.10', reason: 'Deprecation reason' },
description: 'Field description',
feature_flag: 'foo_flag'
)
- expect(field.description).to eq('Field description. Available only when feature flag `foo_flag` is enabled. Deprecated in 1.10: Deprecation reason')
+ expectation = 'Field description. Available only when feature flag `foo_flag` is enabled. Deprecated in 1.10: Deprecation reason'
+
+ expect(field.description).to eq(expectation)
end
end
end
diff --git a/spec/graphql/types/boards/board_issue_input_type_spec.rb b/spec/graphql/types/boards/board_issue_input_type_spec.rb
new file mode 100644
index 00000000000..6319ff9a88e
--- /dev/null
+++ b/spec/graphql/types/boards/board_issue_input_type_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['BoardIssueInput'] do
+ it { expect(described_class.graphql_name).to eq('BoardIssueInput') }
+
+ it 'exposes negated issue arguments' do
+ allowed_args = %w(labelName milestoneTitle assigneeUsername authorUsername
+ releaseTag myReactionEmoji not search)
+
+ expect(described_class.arguments.keys).to include(*allowed_args)
+ expect(described_class.arguments['not'].type).to eq(Types::Boards::NegatedBoardIssueInputType)
+ end
+end
diff --git a/spec/graphql/types/current_user_todos_type_spec.rb b/spec/graphql/types/current_user_todos_type_spec.rb
new file mode 100644
index 00000000000..a0015e96788
--- /dev/null
+++ b/spec/graphql/types/current_user_todos_type_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CurrentUserTodos'] do
+ specify { expect(described_class.graphql_name).to eq('CurrentUserTodos') }
+
+ specify { expect(described_class).to have_graphql_fields(:current_user_todos).only }
+end
diff --git a/spec/graphql/types/design_management/design_type_spec.rb b/spec/graphql/types/design_management/design_type_spec.rb
index 7a38b397965..cae98a013e1 100644
--- a/spec/graphql/types/design_management/design_type_spec.rb
+++ b/spec/graphql/types/design_management/design_type_spec.rb
@@ -3,8 +3,10 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['Design'] do
+ specify { expect(described_class.interfaces).to include(Types::CurrentUserTodos) }
+
it_behaves_like 'a GraphQL type with design fields' do
- let(:extra_design_fields) { %i[notes discussions versions] }
+ let(:extra_design_fields) { %i[notes current_user_todos discussions versions] }
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 0b87805c2ef..bf7ddebaf5b 100644
--- a/spec/graphql/types/group_type_spec.rb
+++ b/spec/graphql/types/group_type_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe GitlabSchema.types['Group'] do
web_url avatar_url share_with_group_lock project_creation_level
subgroup_creation_level require_two_factor_authentication
two_factor_grace_period auto_devops_enabled emails_disabled
- mentions_disabled parent boards milestones
+ mentions_disabled parent boards milestones group_members
]
expect(described_class).to include_graphql_fields(*expected_fields)
@@ -30,5 +30,12 @@ RSpec.describe GitlabSchema.types['Group'] do
end
end
+ describe 'members field' do
+ subject { described_class.fields['groupMembers'] }
+
+ it { is_expected.to have_graphql_type(Types::GroupMemberType.connection_type) }
+ it { is_expected.to have_graphql_resolver(Resolvers::GroupMembersResolver) }
+ end
+
it_behaves_like 'a GraphQL type with labels'
end
diff --git a/spec/graphql/types/issuable_severity_enum_spec.rb b/spec/graphql/types/issuable_severity_enum_spec.rb
new file mode 100644
index 00000000000..3f8bd76fa62
--- /dev/null
+++ b/spec/graphql/types/issuable_severity_enum_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::IssuableSeverityEnum do
+ specify { expect(described_class.graphql_name).to eq('IssuableSeverity') }
+
+ it 'exposes all the existing issuable severity values' do
+ expect(described_class.values.keys).to contain_exactly(
+ *%w[UNKNOWN LOW MEDIUM HIGH CRITICAL]
+ )
+ end
+end
diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb
index 24353f8fe3a..c55e624dd11 100644
--- a/spec/graphql/types/issue_type_spec.rb
+++ b/spec/graphql/types/issue_type_spec.rb
@@ -11,11 +11,13 @@ RSpec.describe GitlabSchema.types['Issue'] do
specify { expect(described_class.interfaces).to include(Types::Notes::NoteableType) }
+ specify { expect(described_class.interfaces).to include(Types::CurrentUserTodos) }
+
it 'has specific fields' do
fields = %i[id iid title description state reference author assignees participants labels milestone due_date
confidential discussion_locked upvotes downvotes user_notes_count web_path web_url relative_position
subscribed time_estimate total_time_spent closed_at created_at updated_at task_completion_status
- designs design_collection]
+ designs design_collection alert_management_alert severity current_user_todos]
fields.each do |field_name|
expect(described_class).to have_graphql_field(field_name)
diff --git a/spec/graphql/types/member_interface_spec.rb b/spec/graphql/types/member_interface_spec.rb
new file mode 100644
index 00000000000..11fd09eb335
--- /dev/null
+++ b/spec/graphql/types/member_interface_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::MemberInterface do
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ id
+ access_level
+ created_by
+ created_at
+ updated_at
+ expires_at
+ user
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+
+ describe '.resolve_type' do
+ subject { described_class.resolve_type(object, {}) }
+
+ context 'for project member' do
+ let(:object) { build(:project_member) }
+
+ it { is_expected.to be Types::ProjectMemberType }
+ end
+
+ context 'for group member' do
+ let(:object) { build(:group_member) }
+
+ it { is_expected.to be Types::GroupMemberType }
+ end
+
+ context 'for an unkown type' do
+ let(:object) { build(:user) }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::BaseError)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/types/merge_request_sort_enum_spec.rb b/spec/graphql/types/merge_request_sort_enum_spec.rb
new file mode 100644
index 00000000000..472eba1a50d
--- /dev/null
+++ b/spec/graphql/types/merge_request_sort_enum_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['MergeRequestSort'] do
+ specify { expect(described_class.graphql_name).to eq('MergeRequestSort') }
+
+ it_behaves_like 'common sort values'
+
+ it 'exposes all the existing issue sort values' do
+ expect(described_class.values.keys).to include(
+ *%w[MERGED_AT_ASC MERGED_AT_DESC]
+ )
+ end
+end
diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb
index b11951190e0..46aebbdabeb 100644
--- a/spec/graphql/types/merge_request_type_spec.rb
+++ b/spec/graphql/types/merge_request_type_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
specify { expect(described_class.interfaces).to include(Types::Notes::NoteableType) }
+ specify { expect(described_class.interfaces).to include(Types::CurrentUserTodos) }
+
it 'has the expected fields' do
expected_fields = %w[
notes discussions user_permissions id iid title title_html description
@@ -24,10 +26,16 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
source_branch_exists target_branch_exists
upvotes downvotes head_pipeline pipelines task_completion_status
milestone assignees participants subscribed labels discussion_locked time_estimate
- total_time_spent reference author merged_at commit_count
+ total_time_spent reference author merged_at commit_count current_user_todos
+ conflicts auto_merge_enabled
]
- expected_fields << 'approved_by' if Gitlab.ee?
+ if Gitlab.ee?
+ expected_fields << 'approved'
+ expected_fields << 'approvals_left'
+ expected_fields << 'approvals_required'
+ expected_fields << 'approved_by'
+ end
expect(described_class).to have_graphql_fields(*expected_fields)
end
diff --git a/spec/graphql/types/milestone_type_spec.rb b/spec/graphql/types/milestone_type_spec.rb
index 2315c10433b..806495250ac 100644
--- a/spec/graphql/types/milestone_type_spec.rb
+++ b/spec/graphql/types/milestone_type_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe GitlabSchema.types['Milestone'] do
stats
]
- expect(described_class).to have_graphql_fields(*expected_fields)
+ expect(described_class).to have_graphql_fields(*expected_fields).at_least
end
describe 'stats field' do
diff --git a/spec/graphql/types/package_type_enum_spec.rb b/spec/graphql/types/package_type_enum_spec.rb
index fadec9744ed..80a20a68bc2 100644
--- a/spec/graphql/types/package_type_enum_spec.rb
+++ b/spec/graphql/types/package_type_enum_spec.rb
@@ -4,6 +4,6 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['PackageTypeEnum'] do
it 'exposes all package types' do
- expect(described_class.values.keys).to contain_exactly(*%w[MAVEN NPM CONAN NUGET PYPI COMPOSER])
+ expect(described_class.values.keys).to contain_exactly(*%w[MAVEN NPM CONAN NUGET PYPI COMPOSER GENERIC])
end
end
diff --git a/spec/graphql/types/permission_types/merge_request_spec.rb b/spec/graphql/types/permission_types/merge_request_spec.rb
index 73a178540a6..2849dead9a8 100644
--- a/spec/graphql/types/permission_types/merge_request_spec.rb
+++ b/spec/graphql/types/permission_types/merge_request_spec.rb
@@ -7,7 +7,8 @@ RSpec.describe Types::PermissionTypes::MergeRequest do
expected_permissions = [
:read_merge_request, :admin_merge_request, :update_merge_request,
:create_note, :push_to_source_branch, :remove_source_branch,
- :cherry_pick_on_current_merge_request, :revert_on_current_merge_request
+ :cherry_pick_on_current_merge_request, :revert_on_current_merge_request,
+ :can_merge
]
expect(described_class).to have_graphql_fields(expected_permissions)
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 8a5d0cdf12d..44a89bfa35e 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -59,7 +59,7 @@ RSpec.describe GitlabSchema.types['Project'] do
subject { described_class.fields['mergeRequests'] }
it { is_expected.to have_graphql_type(Types::MergeRequestType.connection_type) }
- it { is_expected.to have_graphql_resolver(Resolvers::MergeRequestsResolver) }
+ it { is_expected.to have_graphql_resolver(Resolvers::ProjectMergeRequestsResolver) }
it do
is_expected.to have_graphql_arguments(:iids,
@@ -72,7 +72,11 @@ RSpec.describe GitlabSchema.types['Project'] do
:first,
:last,
:merged_after,
- :merged_before
+ :merged_before,
+ :author_username,
+ :assignee_username,
+ :milestone_title,
+ :sort
)
end
end
@@ -108,7 +112,7 @@ RSpec.describe GitlabSchema.types['Project'] do
describe 'members field' do
subject { described_class.fields['projectMembers'] }
- it { is_expected.to have_graphql_type(Types::ProjectMemberType.connection_type) }
+ it { is_expected.to have_graphql_type(Types::MemberInterface.connection_type) }
it { is_expected.to have_graphql_resolver(Resolvers::ProjectMembersResolver) }
end
diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb
index ab13162b406..11f780a4f3f 100644
--- a/spec/graphql/types/query_type_spec.rb
+++ b/spec/graphql/types/query_type_spec.rb
@@ -20,6 +20,8 @@ RSpec.describe GitlabSchema.types['Query'] do
milestone
user
users
+ issue
+ instance_statistics_measurements
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
@@ -53,4 +55,20 @@ RSpec.describe GitlabSchema.types['Query'] do
is_expected.to have_graphql_resolver(Resolvers::MetadataResolver)
end
end
+
+ describe 'issue field' do
+ subject { described_class.fields['issue'] }
+
+ it 'returns issue' do
+ is_expected.to have_graphql_type(Types::IssueType)
+ end
+ end
+
+ describe 'instance_statistics_measurements field' do
+ subject { described_class.fields['instanceStatisticsMeasurements'] }
+
+ it 'returns issue' do
+ is_expected.to have_graphql_type(Types::Admin::Analytics::InstanceStatistics::MeasurementType.connection_type)
+ end
+ end
end
diff --git a/spec/graphql/types/release_asset_link_type_spec.rb b/spec/graphql/types/release_asset_link_type_spec.rb
index 679431012cf..6800d5459c4 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
+ id name url external link_type direct_asset_url
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index 7710b8efefe..1d5af24b3d9 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -25,6 +25,7 @@ RSpec.describe GitlabSchema.types['User'] do
assignedMergeRequests
groupMemberships
projectMemberships
+ starredProjects
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb
index 1e843ee221b..e0d316baa17 100644
--- a/spec/helpers/auth_helper_spec.rb
+++ b/spec/helpers/auth_helper_spec.rb
@@ -220,4 +220,44 @@ RSpec.describe AuthHelper do
it { is_expected.to be(false) }
end
end
+
+ describe '#auth_active?' do
+ let(:user) { create(:user) }
+
+ def auth_active?
+ helper.auth_active?(provider)
+ end
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ context 'for atlassian_oauth2 provider' do
+ let_it_be(:provider) { :atlassian_oauth2 }
+
+ it 'returns true when present' do
+ create(:atlassian_identity, user: user)
+
+ expect(auth_active?).to be true
+ end
+
+ it 'returns false when not present' do
+ expect(auth_active?).to be false
+ end
+ end
+
+ context 'for other omniauth providers' do
+ let_it_be(:provider) { 'google_oauth2' }
+
+ it 'returns true when present' do
+ create(:identity, provider: provider, user: user)
+
+ expect(auth_active?).to be true
+ end
+
+ it 'returns false when not present' do
+ expect(auth_active?).to be false
+ end
+ end
+ end
end
diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb
index 3ba9f39d21a..06f86e7716a 100644
--- a/spec/helpers/blob_helper_spec.rb
+++ b/spec/helpers/blob_helper_spec.rb
@@ -481,4 +481,59 @@ RSpec.describe BlobHelper do
end
end
end
+
+ describe '#editing_ci_config?' do
+ let(:project) { build(:project) }
+
+ subject { helper.editing_ci_config? }
+
+ before do
+ assign(:project, project)
+ assign(:path, path)
+ end
+
+ context 'when path is nil' do
+ let(:path) { nil }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when path is not a ci file' do
+ let(:path) { 'some-file.txt' }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when path ends is gitlab-ci.yml' do
+ let(:path) { '.gitlab-ci.yml' }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when path ends with gitlab-ci.yml' do
+ let(:path) { 'template.gitlab-ci.yml' }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'with custom ci paths' do
+ let(:path) { 'path/to/ci.yaml' }
+
+ before do
+ project.ci_config_path = 'path/to/ci.yaml'
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'with custom ci config and path' do
+ let(:path) { 'path/to/template.gitlab-ci.yml' }
+
+ before do
+ project.ci_config_path = 'ci/path/.gitlab-ci.yml@another-group/another-project'
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
end
diff --git a/spec/helpers/ci/pipelines_helper_spec.rb b/spec/helpers/ci/pipelines_helper_spec.rb
index 89b9907d0c2..a96d6e7711f 100644
--- a/spec/helpers/ci/pipelines_helper_spec.rb
+++ b/spec/helpers/ci/pipelines_helper_spec.rb
@@ -22,8 +22,8 @@ RSpec.describe Ci::PipelinesHelper do
let(:warning_messages) { [double(content: 'Warning 1'), double(content: 'Warning 2')] }
it 'returns a warning callout box' do
- expect(subject).to have_css 'div.alert-warning'
- expect(subject).to include 'Warning:'
+ expect(subject).to have_css 'div.bs-callout-warning'
+ expect(subject).to include '2 warning(s) found:'
end
it 'lists the the warnings' do
@@ -32,4 +32,24 @@ RSpec.describe Ci::PipelinesHelper do
end
end
end
+
+ describe 'warning_header' do
+ subject { helper.warning_header(count) }
+
+ context 'when warnings are more than max cap' do
+ let(:count) { 30 }
+
+ it 'returns 30 warning(s) found: showing first 25' do
+ expect(subject).to eq('30 warning(s) found: showing first 25')
+ end
+ end
+
+ context 'when warnings are less than max cap' do
+ let(:count) { 15 }
+
+ it 'returns 15 warning(s) found' do
+ expect(subject).to eq('15 warning(s) found:')
+ end
+ end
+ end
end
diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb
index dff83005c89..6164f3b5e8d 100644
--- a/spec/helpers/clusters_helper_spec.rb
+++ b/spec/helpers/clusters_helper_spec.rb
@@ -77,7 +77,15 @@ RSpec.describe ClustersHelper do
end
it 'displays and ancestor_help_path' do
- expect(subject[:ancestor_help_path]).to eq('/help/user/group/clusters/index#cluster-precedence')
+ expect(subject[:ancestor_help_path]).to eq(help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'))
+ end
+ end
+
+ describe '#js_cluster_new' do
+ subject { helper.js_cluster_new }
+
+ it 'displays a cluster_connect_help_path' do
+ expect(subject[:cluster_connect_help_path]).to eq(help_page_path('user/project/clusters/add_remove_clusters', anchor: 'add-existing-cluster'))
end
end
diff --git a/spec/helpers/container_registry_helper_spec.rb b/spec/helpers/container_registry_helper_spec.rb
new file mode 100644
index 00000000000..6e6e8137b3e
--- /dev/null
+++ b/spec/helpers/container_registry_helper_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ContainerRegistryHelper do
+ using RSpec::Parameterized::TableSyntax
+
+ describe '#limit_delete_tags_service?' do
+ subject { helper.limit_delete_tags_service? }
+
+ where(:feature_flag_enabled, :client_support, :expected_result) do
+ true | true | true
+ true | false | false
+ false | true | false
+ false | false | false
+ end
+
+ with_them do
+ before do
+ stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
+ allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
+ end
+
+ it { is_expected.to eq(expected_result) }
+ end
+ end
+end
diff --git a/spec/helpers/dropdowns_helper_spec.rb b/spec/helpers/dropdowns_helper_spec.rb
new file mode 100644
index 00000000000..fd1125d0024
--- /dev/null
+++ b/spec/helpers/dropdowns_helper_spec.rb
@@ -0,0 +1,253 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DropdownsHelper do
+ before do
+ allow(helper).to receive(:sprite_icon).and_return('<span class="icon"></span>'.html_safe)
+ allow(helper).to receive(:icon).and_return('<span class="icon"></span>'.html_safe)
+ end
+
+ shared_examples 'has two icons' do
+ it 'returns two icons' do
+ expect(content.scan('icon').count).to eq(2)
+ end
+ end
+
+ describe '#dropdown_tag' do
+ let(:content) { helper.dropdown_tag('toggle', options: { wrapper_class: 'fuz' }) { 'fizzbuzz' } }
+
+ it 'returns the container in the content' do
+ expect(content).to include('dropdown fuz')
+ end
+
+ it 'returns the block in the content' do
+ expect(content).to include('fizzbuzz')
+ end
+ end
+
+ describe '#dropdown_toggle' do
+ let(:content) { helper.dropdown_toggle('foo', { default_label: 'foo' }, { toggle_class: 'fuz' }) }
+
+ it 'returns the button' do
+ expect(content).to include('dropdown-menu-toggle fuz')
+ end
+
+ it 'returns the buttons default label data attribute' do
+ expect(content).to include('data-default-label="foo"')
+ end
+
+ it 'returns the dropdown toggle text', :aggregate_failures do
+ expect(content).to include('dropdown-toggle-text is-default')
+ expect(content).to include('foo')
+ end
+
+ it 'returns the button icon in the content' do
+ expect(content.scan('icon').count).to eq(1)
+ end
+ end
+
+ describe '#dropdown_toggle_link' do
+ let(:content) { dropdown_toggle_link('foo', { data: 'bar' }, { toggle_class: 'fuz' }) }
+
+ it 'returns the link' do
+ expect(content).to include('dropdown-toggle-text fuz')
+ end
+
+ it 'returns the links data attribute' do
+ expect(content).to include('data-data="bar"')
+ end
+
+ it 'returns the link text' do
+ expect(content).to include('foo')
+ end
+ end
+
+ describe '#dropdown_title' do
+ shared_examples 'has a back button' do
+ it 'contains the back button' do
+ expect(content).to include('dropdown-title-button dropdown-menu-back')
+ end
+ end
+
+ shared_examples 'does not have a back button' do
+ it 'does not contain the back button' do
+ expect(content).not_to include('dropdown-title-button dropdown-menu-back')
+ end
+ end
+
+ shared_examples 'does not apply the margin class to the back button' do
+ it 'does not contain the back button margin class' do
+ expect(content).not_to include('dropdown-title-button dropdown-menu-back gl-mr-auto')
+ end
+ end
+
+ shared_examples 'has a close button' do
+ it 'contains the close button' do
+ expect(content).to include('dropdown-title-button dropdown-menu-close')
+ end
+ end
+
+ shared_examples 'does not have a close button' do
+ it 'does not contain the close button' do
+ expect(content).not_to include('dropdown-title-button dropdown-menu-close')
+ end
+ end
+
+ shared_examples 'does not apply the margin class to the close button' do
+ it 'does not contain the close button margin class' do
+ expect(content).not_to include('dropdown-title-button dropdown-menu-close gl-ml-auto')
+ end
+ end
+
+ shared_examples 'has the title text' do
+ it 'contains the title text' do
+ expect(content).to include('Foo')
+ end
+ end
+
+ shared_examples 'has the title margin class' do |margin_class: ''|
+ it 'contains the title margin class' do
+ expect(content).to match(/class="#{margin_class}.*"[^>]*>Foo/)
+ end
+ end
+
+ shared_examples 'does not have the title margin class' do
+ it 'does not have the title margin class' do
+ expect(content).not_to match(/class="gl-m[r|l]-auto.*"[^>]*>Foo/)
+ end
+ end
+
+ context 'with a back and close button' do
+ let(:content) { helper.dropdown_title('Foo', options: { back: true, close: true }) }
+
+ it 'applies the justification class to the container', :aggregate_failures do
+ expect(content).to match(/"dropdown-title.*gl-justify-content-space-between"/)
+ end
+
+ it_behaves_like 'has a back button'
+ it_behaves_like 'has the title text'
+ it_behaves_like 'has a close button'
+ it_behaves_like 'has two icons'
+ it_behaves_like 'does not have the title margin class'
+ end
+
+ context 'with a back button' do
+ let(:content) { helper.dropdown_title('Foo', options: { back: true, close: false }) }
+
+ it_behaves_like 'has a back button'
+ it_behaves_like 'has the title text'
+ it_behaves_like 'has the title margin class', margin_class: 'gl-mr-auto'
+ it_behaves_like 'does not have a close button'
+
+ it 'returns the back button icon' do
+ expect(content.scan('icon').count).to eq(1)
+ end
+ end
+
+ context 'with a close button' do
+ let(:content) { helper.dropdown_title('Foo', options: { back: false, close: true }) }
+
+ it_behaves_like 'does not have a back button'
+ it_behaves_like 'has the title text'
+ it_behaves_like 'has the title margin class', margin_class: 'gl-ml-auto'
+ it_behaves_like 'has a close button'
+
+ it 'returns the close button icon' do
+ expect(content.scan('icon').count).to eq(1)
+ end
+ end
+
+ context 'without any buttons' do
+ let(:content) { helper.dropdown_title('Foo', options: { back: false, close: false }) }
+
+ it_behaves_like 'does not have a back button'
+ it_behaves_like 'has the title text'
+ it_behaves_like 'does not have the title margin class'
+ it_behaves_like 'does not have a close button'
+
+ it 'returns no button icons' do
+ expect(content.scan('icon').count).to eq(0)
+ end
+ end
+ end
+
+ describe '#dropdown_filter' do
+ let(:content) { helper.dropdown_filter('foo') }
+
+ it_behaves_like 'has two icons'
+
+ it 'returns the container' do
+ expect(content).to include('dropdown-input')
+ end
+
+ it 'returns the search input', :aggregate_failures do
+ expect(content).to include('dropdown-input-field')
+ expect(content).to include('placeholder="foo"')
+ end
+ end
+
+ describe '#dropdown_content' do
+ shared_examples 'contains the container' do
+ it 'returns the container in the content' do
+ expect(content).to include('dropdown-content')
+ end
+ end
+
+ context 'without block' do
+ let(:content) { helper.dropdown_content }
+
+ it_behaves_like 'contains the container'
+ end
+
+ context 'with block' do
+ let(:content) { helper.dropdown_content { 'foo' } }
+
+ it_behaves_like 'contains the container'
+
+ it 'returns the block in the content' do
+ expect(content).to include('foo')
+ end
+ end
+ end
+
+ describe '#dropdown_footer' do
+ shared_examples 'contains the content' do
+ it 'returns the container in the content' do
+ expect(content).to include('dropdown-footer')
+ end
+
+ it 'returns the block in the content' do
+ expect(content).to include('foo')
+ end
+ end
+
+ context 'without a content class' do
+ let(:content) { helper.dropdown_footer { 'foo' } }
+
+ it_behaves_like 'contains the content'
+ end
+
+ context 'without a content class' do
+ let(:content) { helper.dropdown_footer(add_content_class: true) { 'foo' } }
+
+ it_behaves_like 'contains the content'
+
+ it 'returns the footer in the content' do
+ expect(content).to include('dropdown-footer-content')
+ end
+ end
+ end
+
+ describe '#dropdown_loading' do
+ let(:content) { helper.dropdown_loading }
+
+ it 'returns the container in the content' do
+ expect(content).to include('dropdown-loading')
+ end
+
+ it 'returns an icon in the content' do
+ expect(content.scan('icon').count).to eq(1)
+ end
+ end
+end
diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb
index bc5fe05ab52..96ac4015c77 100644
--- a/spec/helpers/emails_helper_spec.rb
+++ b/spec/helpers/emails_helper_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe EmailsHelper do
context "and format is unknown" do
it "returns plain text" do
- expect(helper.closure_reason_text(merge_request, format: :text)).to eq("via merge request #{merge_request.to_reference} (#{merge_request_presenter.web_url})")
+ expect(helper.closure_reason_text(merge_request, format: 'unknown')).to eq("via merge request #{merge_request.to_reference} (#{merge_request_presenter.web_url})")
end
end
end
@@ -110,6 +110,76 @@ RSpec.describe EmailsHelper do
end
end
+ describe '#say_hi' do
+ let(:user) { create(:user, name: 'John') }
+
+ it 'returns the greeting message for the given user' do
+ expect(say_hi(user)).to eq('Hi John!')
+ end
+ end
+
+ describe '#say_hello' do
+ let(:user) { build(:user, name: 'John') }
+
+ it 'returns the greeting message for the given user' do
+ expect(say_hello(user)).to eq('Hello, John!')
+ end
+ end
+
+ describe '#two_factor_authentication_disabled_text' do
+ it 'returns the message that 2FA is disabled' do
+ expect(two_factor_authentication_disabled_text).to eq(
+ _('Two-factor authentication has been disabled for your GitLab account.')
+ )
+ end
+ end
+
+ describe '#re_enable_two_factor_authentication_text' do
+ context 'format is html' do
+ it 'returns HTML' do
+ expect(re_enable_two_factor_authentication_text(format: :html)).to eq(
+ "If you want to re-enable two-factor authentication, visit the " \
+ "#{link_to('two-factor authentication settings', profile_two_factor_auth_url, target: :_blank, rel: 'noopener noreferrer')} page."
+ )
+ end
+ end
+
+ context 'format is not specified' do
+ it 'returns text' do
+ expect(re_enable_two_factor_authentication_text).to eq(
+ "If you want to re-enable two-factor authentication, visit #{profile_two_factor_auth_url}"
+ )
+ end
+ end
+ end
+
+ describe '#admin_changed_password_text' do
+ context 'format is html' do
+ it 'returns HTML' do
+ expect(admin_changed_password_text(format: :html)).to eq(
+ "An administrator changed the password for your GitLab account on " \
+ "#{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url, target: :_blank, rel: 'noopener noreferrer')}."
+ )
+ end
+ end
+
+ context 'format is not specified' do
+ it 'returns text' do
+ expect(admin_changed_password_text).to eq(
+ "An administrator changed the password for your GitLab account on #{Gitlab.config.gitlab.url}."
+ )
+ end
+ end
+ end
+
+ describe '#contact_your_administrator_text' do
+ it 'returns the message to contact the administrator' do
+ expect(contact_your_administrator_text).to eq(
+ _('Please contact your administrator with any questions.')
+ )
+ end
+ end
+
describe 'password_reset_token_valid_time' do
def validate_time_string(time_limit, expected_string)
Devise.reset_password_within = time_limit
diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb
index cb7d12b331a..d316f2b0a0a 100644
--- a/spec/helpers/environments_helper_spec.rb
+++ b/spec/helpers/environments_helper_spec.rb
@@ -18,34 +18,34 @@ RSpec.describe EnvironmentsHelper do
it 'returns data' do
expect(metrics_data).to include(
- 'settings-path' => edit_project_service_path(project, 'prometheus'),
- 'clusters-path' => project_clusters_path(project),
- 'metrics-dashboard-base-path' => environment_metrics_path(environment),
- 'current-environment-name' => environment.name,
- 'documentation-path' => help_page_path('administration/monitoring/prometheus/index.md'),
- 'add-dashboard-documentation-path' => help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'),
- 'empty-getting-started-svg-path' => match_asset_path('/assets/illustrations/monitoring/getting_started.svg'),
- 'empty-loading-svg-path' => match_asset_path('/assets/illustrations/monitoring/loading.svg'),
- 'empty-no-data-svg-path' => match_asset_path('/assets/illustrations/monitoring/no_data.svg'),
- 'empty-unable-to-connect-svg-path' => match_asset_path('/assets/illustrations/monitoring/unable_to_connect.svg'),
- 'metrics-endpoint' => additional_metrics_project_environment_path(project, environment, format: :json),
- 'deployments-endpoint' => project_environment_deployments_path(project, environment, format: :json),
- 'default-branch' => 'master',
- 'project-path' => project_path(project),
- 'tags-path' => project_tags_path(project),
- 'has-metrics' => "#{environment.has_metrics?}",
- 'prometheus-status' => "#{environment.prometheus_status}",
- 'external-dashboard-url' => nil,
- 'environment-state' => environment.state,
- 'custom-metrics-path' => project_prometheus_metrics_path(project),
- 'validate-query-path' => validate_query_project_prometheus_metrics_path(project),
- 'custom-metrics-available' => 'true',
- 'alerts-endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json),
- 'prometheus-alerts-available' => 'true',
- 'custom-dashboard-base-path' => Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT,
- 'operations-settings-path' => project_settings_operations_path(project),
- 'can-access-operations-settings' => 'true',
- 'panel-preview-endpoint' => project_metrics_dashboards_builder_path(project, format: :json)
+ 'settings_path' => edit_project_service_path(project, 'prometheus'),
+ 'clusters_path' => project_clusters_path(project),
+ 'metrics_dashboard_base_path' => environment_metrics_path(environment),
+ 'current_environment_name' => environment.name,
+ 'documentation_path' => help_page_path('administration/monitoring/prometheus/index.md'),
+ 'add_dashboard_documentation_path' => help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'),
+ 'empty_getting_started_svg_path' => match_asset_path('/assets/illustrations/monitoring/getting_started.svg'),
+ 'empty_loading_svg_path' => match_asset_path('/assets/illustrations/monitoring/loading.svg'),
+ 'empty_no_data_svg_path' => match_asset_path('/assets/illustrations/monitoring/no_data.svg'),
+ 'empty_unable_to_connect_svg_path' => match_asset_path('/assets/illustrations/monitoring/unable_to_connect.svg'),
+ 'metrics_endpoint' => additional_metrics_project_environment_path(project, environment, format: :json),
+ 'deployments_endpoint' => project_environment_deployments_path(project, environment, format: :json),
+ 'default_branch' => 'master',
+ 'project_path' => project_path(project),
+ 'tags_path' => project_tags_path(project),
+ 'has_metrics' => "#{environment.has_metrics?}",
+ 'prometheus_status' => "#{environment.prometheus_status}",
+ 'external_dashboard_url' => nil,
+ 'environment_state' => environment.state,
+ 'custom_metrics_path' => project_prometheus_metrics_path(project),
+ 'validate_query_path' => validate_query_project_prometheus_metrics_path(project),
+ 'custom_metrics_available' => 'true',
+ 'alerts_endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json),
+ 'prometheus_alerts_available' => 'true',
+ 'custom_dashboard_base_path' => Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT,
+ 'operations_settings_path' => project_settings_operations_path(project),
+ 'can_access_operations_settings' => 'true',
+ 'panel_preview_endpoint' => project_metrics_dashboards_builder_path(project, format: :json)
)
end
@@ -58,7 +58,7 @@ RSpec.describe EnvironmentsHelper do
specify do
expect(metrics_data).to include(
- 'can-access-operations-settings' => 'false'
+ 'can_access_operations_settings' => 'false'
)
end
end
@@ -72,7 +72,7 @@ RSpec.describe EnvironmentsHelper do
it 'returns false' do
expect(metrics_data).to include(
- 'prometheus-alerts-available' => 'false'
+ 'prometheus_alerts_available' => 'false'
)
end
end
@@ -83,7 +83,7 @@ RSpec.describe EnvironmentsHelper do
end
it 'adds external_dashboard_url' do
- expect(metrics_data['external-dashboard-url']).to eq('http://gitlab.com')
+ expect(metrics_data['external_dashboard_url']).to eq('http://gitlab.com')
end
end
@@ -94,7 +94,7 @@ RSpec.describe EnvironmentsHelper do
subject { metrics_data }
- it { is_expected.to include('environment-state' => 'stopped') }
+ it { is_expected.to include('environment_state' => 'stopped') }
end
context 'when request is from project scoped metrics path' do
@@ -107,16 +107,16 @@ RSpec.describe EnvironmentsHelper do
context '/:namespace/:project/-/metrics' do
let(:path) { project_metrics_dashboard_path(project) }
- it 'uses correct path for metrics-dashboard-base-path' do
- expect(metrics_data['metrics-dashboard-base-path']).to eq(project_metrics_dashboard_path(project))
+ it 'uses correct path for metrics_dashboard_base_path' do
+ expect(metrics_data['metrics_dashboard_base_path']).to eq(project_metrics_dashboard_path(project))
end
end
context '/:namespace/:project/-/metrics/some_custom_dashboard.yml' do
let(:path) { "#{project_metrics_dashboard_path(project)}/some_custom_dashboard.yml" }
- it 'uses correct path for metrics-dashboard-base-path' do
- expect(metrics_data['metrics-dashboard-base-path']).to eq(project_metrics_dashboard_path(project))
+ it 'uses correct path for metrics_dashboard_base_path' do
+ expect(metrics_data['metrics_dashboard_base_path']).to eq(project_metrics_dashboard_path(project))
end
end
end
@@ -143,11 +143,11 @@ RSpec.describe EnvironmentsHelper do
describe '#environment_logs_data' do
it 'returns logs data' do
expected_data = {
- "environment-name": environment.name,
- "environments-path": project_environments_path(project, format: :json),
- "environment-id": environment.id,
- "cluster-applications-documentation-path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack'),
- "clusters-path": project_clusters_path(project, format: :json)
+ "environment_name": environment.name,
+ "environments_path": project_environments_path(project, format: :json),
+ "environment_id": environment.id,
+ "cluster_applications_documentation_path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack'),
+ "clusters_path": project_clusters_path(project, format: :json)
}
expect(helper.environment_logs_data(project, environment)).to eq(expected_data)
diff --git a/spec/helpers/groups/group_members_helper_spec.rb b/spec/helpers/groups/group_members_helper_spec.rb
index 90792331d9b..a25bf1c4157 100644
--- a/spec/helpers/groups/group_members_helper_spec.rb
+++ b/spec/helpers/groups/group_members_helper_spec.rb
@@ -3,6 +3,8 @@
require "spec_helper"
RSpec.describe Groups::GroupMembersHelper do
+ include MembersPresentation
+
describe '.group_member_select_options' do
let(:group) { create(:group) }
@@ -14,4 +16,50 @@ RSpec.describe Groups::GroupMembersHelper do
expect(helper.group_member_select_options).to include(multiple: true, scope: :all, email_user: true)
end
end
+
+ describe '#linked_groups_data_json' do
+ include_context 'group_group_link'
+
+ it 'matches json schema' do
+ json = helper.linked_groups_data_json(shared_group.shared_with_group_links)
+
+ expect(json).to match_schema('group_group_links')
+ end
+ end
+
+ describe '#members_data_json' do
+ let(:current_user) { create(:user) }
+ let(:group) { create(:group) }
+
+ before do
+ allow(helper).to receive(:can?).with(current_user, :owner_access, group).and_return(true)
+ allow(helper).to receive(:current_user).and_return(current_user)
+ end
+
+ shared_examples 'group_members.json' do
+ it 'matches json schema' do
+ json = helper.members_data_json(group, present_members([group_member]))
+
+ expect(json).to match_schema('group_members')
+ end
+ end
+
+ context 'for a group member' do
+ let(:group_member) { create(:group_member, group: group, created_by: current_user) }
+
+ it_behaves_like 'group_members.json'
+ end
+
+ context 'for an invited group member' do
+ let(:group_member) { create(:group_member, :invited, group: group, created_by: current_user) }
+
+ it_behaves_like 'group_members.json'
+ end
+
+ context 'for an access request' do
+ let(:group_member) { create(:group_member, :access_request, group: group, created_by: current_user) }
+
+ it_behaves_like 'group_members.json'
+ end
+ end
end
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index 0790dc1b674..08b25d64b43 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -369,4 +369,48 @@ RSpec.describe GroupsHelper do
it { is_expected.to be_falsey }
end
end
+
+ describe '#show_invite_banner?' do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be_with_refind(:group) { create(:group) }
+ let_it_be(:users) { [current_user, create(:user)] }
+
+ subject { helper.show_invite_banner?(group) }
+
+ before do
+ allow(helper).to receive(:current_user) { current_user }
+ allow(helper).to receive(:can?).with(current_user, :admin_group, group).and_return(can_admin_group)
+ stub_feature_flags(invite_your_teammates_banner_a: feature_enabled_flag)
+ users.take(group_members_count).each { |user| group.add_guest(user) }
+ end
+
+ using RSpec::Parameterized::TableSyntax
+
+ where(:feature_enabled_flag, :can_admin_group, :group_members_count, :expected_result) do
+ true | true | 1 | true
+ true | false | 1 | false
+ false | true | 1 | false
+ false | false | 1 | false
+ true | true | 2 | false
+ true | false | 2 | false
+ false | true | 2 | false
+ false | false | 2 | false
+ end
+
+ with_them do
+ context 'when the group was just created' do
+ before do
+ flash[:notice] = "Group #{group.name} was successfully created"
+ end
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when no flash message' do
+ it 'returns the expected result' do
+ expect(subject).to eq(expected_result)
+ end
+ end
+ end
+ end
end
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 9b32758c053..89a2a92ea57 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -197,7 +197,9 @@ RSpec.describe IssuablesHelper do
initialTitleText: issue.title,
initialDescriptionHtml: '<p dir="auto">issue text</p>',
initialDescriptionText: 'issue text',
- initialTaskStatus: '0 of 0 tasks completed'
+ initialTaskStatus: '0 of 0 tasks completed',
+ issueType: 'issue',
+ iid: issue.iid.to_s
}
expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
end
diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb
index fcb9efa39d5..153dc19335b 100644
--- a/spec/helpers/merge_requests_helper_spec.rb
+++ b/spec/helpers/merge_requests_helper_spec.rb
@@ -75,7 +75,7 @@ RSpec.describe MergeRequestsHelper do
describe '#tab_link_for' do
let(:merge_request) { create(:merge_request, :simple) }
- let(:options) { Hash.new }
+ let(:options) { {} }
subject { tab_link_for(merge_request, :show, options) { 'Discussion' } }
diff --git a/spec/helpers/notifications_helper_spec.rb b/spec/helpers/notifications_helper_spec.rb
index 8d2806cbef6..176585e2ded 100644
--- a/spec/helpers/notifications_helper_spec.rb
+++ b/spec/helpers/notifications_helper_spec.rb
@@ -4,12 +4,12 @@ require 'spec_helper'
RSpec.describe NotificationsHelper do
describe 'notification_icon' do
- it { expect(notification_icon(:disabled)).to match('class="fa fa-microphone-slash fa-fw"') }
- it { expect(notification_icon(:owner_disabled)).to match('class="fa fa-microphone-slash fa-fw"') }
- it { expect(notification_icon(:participating)).to match('class="fa fa-volume-up fa-fw"') }
- it { expect(notification_icon(:mention)).to match('class="fa fa-at fa-fw"') }
- it { expect(notification_icon(:global)).to match('class="fa fa-globe fa-fw"') }
- it { expect(notification_icon(:watch)).to match('class="fa fa-eye fa-fw"') }
+ it { expect(notification_icon(:disabled)).to match('data-testid="notifications-off-icon"') }
+ it { expect(notification_icon(:owner_disabled)).to match('data-testid="notifications-off-icon"') }
+ it { expect(notification_icon(:participating)).to match('data-testid="notifications-icon"') }
+ it { expect(notification_icon(:mention)).to match('data-testid="at-icon"') }
+ it { expect(notification_icon(:global)).to match('data-testid="earth-icon') }
+ it { expect(notification_icon(:watch)).to match('data-testid="eye-icon"') }
end
describe 'notification_title' do
diff --git a/spec/helpers/operations_helper_spec.rb b/spec/helpers/operations_helper_spec.rb
index 8e3b1db5272..3dac2cf54dc 100644
--- a/spec/helpers/operations_helper_spec.rb
+++ b/spec/helpers/operations_helper_spec.rb
@@ -137,7 +137,8 @@ RSpec.describe OperationsHelper do
:project_incident_management_setting,
project: project,
issue_template_key: 'template-key',
- pagerduty_active: true
+ pagerduty_active: true,
+ auto_close_incident: false
)
end
@@ -150,6 +151,7 @@ RSpec.describe OperationsHelper do
create_issue: 'false',
issue_template_key: 'template-key',
send_email: 'false',
+ auto_close_incident: 'false',
pagerduty_active: 'true',
pagerduty_token: operations_settings.pagerduty_token,
pagerduty_webhook_url: project_incidents_integrations_pagerduty_url(project, token: operations_settings.pagerduty_token),
diff --git a/spec/helpers/releases_helper_spec.rb b/spec/helpers/releases_helper_spec.rb
index 6ae99648ff3..f10a2ed8e60 100644
--- a/spec/helpers/releases_helper_spec.rb
+++ b/spec/helpers/releases_helper_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe ReleasesHelper do
let(:release) { create(:release, project: project) }
let(:user) { create(:user) }
let(:can_user_create_release) { false }
- let(:common_keys) { [:project_id, :illustration_path, :documentation_path] }
+ let(:common_keys) { [:project_id, :project_path, :illustration_path, :documentation_path] }
# rubocop: disable CodeReuse/ActiveRecord
before do
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index 699232e67b1..594c5c11994 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe SearchHelper do
+ include MarkupHelper
+
# Override simple_sanitize for our testing purposes
def simple_sanitize(str)
str
@@ -71,6 +73,72 @@ RSpec.describe SearchHelper do
expect(result.keys).to match_array(%i[category id label url avatar_url])
end
+ it 'includes the first 5 of the users recent issues' do
+ recent_issues = instance_double(::Gitlab::Search::RecentIssues)
+ expect(::Gitlab::Search::RecentIssues).to receive(:new).with(user: user).and_return(recent_issues)
+ project1 = create(:project, :with_avatar, namespace: user.namespace)
+ project2 = create(:project, namespace: user.namespace)
+ issue1 = create(:issue, title: 'issue 1', project: project1)
+ issue2 = create(:issue, title: 'issue 2', project: project2)
+
+ other_issues = create_list(:issue, 5)
+
+ expect(recent_issues).to receive(:search).with('the search term').and_return(Issue.id_in_ordered([issue1.id, issue2.id, *other_issues.map(&:id)]))
+
+ results = search_autocomplete_opts("the search term")
+
+ expect(results.count).to eq(5)
+
+ expect(results[0]).to include({
+ category: 'Recent issues',
+ id: issue1.id,
+ label: 'issue 1',
+ url: Gitlab::Routing.url_helpers.project_issue_path(issue1.project, issue1),
+ avatar_url: project1.avatar_url
+ })
+
+ expect(results[1]).to include({
+ category: 'Recent issues',
+ id: issue2.id,
+ label: 'issue 2',
+ url: Gitlab::Routing.url_helpers.project_issue_path(issue2.project, issue2),
+ avatar_url: '' # This project didn't have an avatar so set this to ''
+ })
+ end
+
+ it 'includes the first 5 of the users recent merge requests' do
+ recent_merge_requests = instance_double(::Gitlab::Search::RecentMergeRequests)
+ expect(::Gitlab::Search::RecentMergeRequests).to receive(:new).with(user: user).and_return(recent_merge_requests)
+ project1 = create(:project, :with_avatar, namespace: user.namespace)
+ project2 = create(:project, namespace: user.namespace)
+ merge_request1 = create(:merge_request, :unique_branches, title: 'Merge request 1', target_project: project1, source_project: project1)
+ merge_request2 = create(:merge_request, :unique_branches, title: 'Merge request 2', target_project: project2, source_project: project2)
+
+ other_merge_requests = create_list(:merge_request, 5)
+
+ expect(recent_merge_requests).to receive(:search).with('the search term').and_return(MergeRequest.id_in_ordered([merge_request1.id, merge_request2.id, *other_merge_requests.map(&:id)]))
+
+ results = search_autocomplete_opts("the search term")
+
+ expect(results.count).to eq(5)
+
+ expect(results[0]).to include({
+ category: 'Recent merge requests',
+ id: merge_request1.id,
+ label: 'Merge request 1',
+ url: Gitlab::Routing.url_helpers.project_merge_request_path(merge_request1.project, merge_request1),
+ avatar_url: project1.avatar_url
+ })
+
+ expect(results[1]).to include({
+ category: 'Recent merge requests',
+ id: merge_request2.id,
+ label: 'Merge request 2',
+ url: Gitlab::Routing.url_helpers.project_merge_request_path(merge_request2.project, merge_request2),
+ avatar_url: '' # This project didn't have an avatar so set this to ''
+ })
+ end
+
it "does not include the public group" do
group = create(:group)
expect(search_autocomplete_opts(group.name).size).to eq(0)
@@ -228,6 +296,20 @@ RSpec.describe SearchHelper do
end
end
+ describe 'search_md_sanitize' do
+ it 'does not do extra sql queries for partial markdown rendering' do
+ @project = create(:project)
+
+ description = FFaker::Lorem.characters(210)
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { search_md_sanitize(description) }.count
+
+ issues = create_list(:issue, 4, project: @project)
+
+ description_with_issues = description + ' ' + issues.map { |issue| "##{issue.iid}" }.join(' ')
+ expect { search_md_sanitize(description_with_issues) }.not_to exceed_all_query_limit(control_count)
+ end
+ end
+
describe 'search_filter_link' do
it 'renders a search filter link for the current scope' do
@scope = 'projects'
diff --git a/spec/helpers/services_helper_spec.rb b/spec/helpers/services_helper_spec.rb
index 481bc41bcf3..d6b48b3d565 100644
--- a/spec/helpers/services_helper_spec.rb
+++ b/spec/helpers/services_helper_spec.rb
@@ -21,9 +21,42 @@ RSpec.describe ServicesHelper do
:comment_detail,
:trigger_events,
:fields,
- :inherit_from_id
+ :inherit_from_id,
+ :integration_level
)
end
end
end
+
+ describe '#group_level_integrations?' do
+ subject { helper.group_level_integrations? }
+
+ context 'when no group is present' do
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when group is present' do
+ let(:group) { build_stubbed(:group) }
+
+ before do
+ assign(:group, group)
+ end
+
+ context 'when `group_level_integrations` is not enabled' do
+ it 'returns false' do
+ stub_feature_flags(group_level_integrations: false)
+
+ is_expected.to eq(false)
+ end
+ end
+
+ context 'when `group_level_integrations` is enabled for the group' do
+ it 'returns true' do
+ stub_feature_flags(group_level_integrations: group)
+
+ is_expected.to eq(true)
+ end
+ end
+ end
+ end
end
diff --git a/spec/helpers/snippets_helper_spec.rb b/spec/helpers/snippets_helper_spec.rb
index 302122c3990..a3244bec56f 100644
--- a/spec/helpers/snippets_helper_spec.rb
+++ b/spec/helpers/snippets_helper_spec.rb
@@ -109,7 +109,7 @@ RSpec.describe SnippetsHelper do
end
def download_link(url)
- "<a target=\"_blank\" rel=\"noopener noreferrer\" class=\"btn btn-sm has-tooltip\" title=\"Download\" data-container=\"body\" href=\"#{url}?inline=false\"><i aria-hidden=\"true\" data-hidden=\"true\" class=\"fa fa-download\"></i></a>"
+ "<a target=\"_blank\" rel=\"noopener noreferrer\" class=\"btn btn-sm has-tooltip\" title=\"Download\" data-container=\"body\" href=\"#{url}?inline=false\">#{sprite_icon('download')}</a>"
end
end
diff --git a/spec/helpers/storage_helper_spec.rb b/spec/helpers/storage_helper_spec.rb
index eca42c8ce06..255ec2a41b4 100644
--- a/spec/helpers/storage_helper_spec.rb
+++ b/spec/helpers/storage_helper_spec.rb
@@ -22,11 +22,12 @@ RSpec.describe StorageHelper do
end
describe "#storage_counters_details" do
- let(:namespace) { create :namespace }
- let(:project) 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,
diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb
index 426bca2ced2..a419b6b9c84 100644
--- a/spec/helpers/submodule_helper_spec.rb
+++ b/spec/helpers/submodule_helper_spec.rb
@@ -24,7 +24,11 @@ RSpec.describe SubmoduleHelper do
allow(Gitlab.config.gitlab_shell).to receive(:ssh_port).and_return(22) # set this just to be sure
allow(Gitlab.config.gitlab_shell).to receive(:ssh_path_prefix).and_return(Settings.send(:build_gitlab_shell_ssh_path_prefix))
stub_url([config.ssh_user, '@', config.host, ':gitlab-org/gitlab-foss.git'].join(''))
- expect(subject).to eq([namespace_project_path('gitlab-org', 'gitlab-foss'), namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash')])
+ aggregate_failures do
+ expect(subject.web).to eq(namespace_project_path('gitlab-org', 'gitlab-foss'))
+ expect(subject.tree).to eq(namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash'))
+ expect(subject.compare).to be_nil
+ end
end
it 'detects ssh on standard port without a username' do
@@ -32,14 +36,22 @@ RSpec.describe SubmoduleHelper do
allow(Gitlab.config.gitlab_shell).to receive(:ssh_user).and_return('')
allow(Gitlab.config.gitlab_shell).to receive(:ssh_path_prefix).and_return(Settings.send(:build_gitlab_shell_ssh_path_prefix))
stub_url([config.host, ':gitlab-org/gitlab-foss.git'].join(''))
- expect(subject).to eq([namespace_project_path('gitlab-org', 'gitlab-foss'), namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash')])
+ aggregate_failures do
+ expect(subject.web).to eq(namespace_project_path('gitlab-org', 'gitlab-foss'))
+ expect(subject.tree).to eq(namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash'))
+ expect(subject.compare).to be_nil
+ end
end
it 'detects ssh on non-standard port' do
allow(Gitlab.config.gitlab_shell).to receive(:ssh_port).and_return(2222)
allow(Gitlab.config.gitlab_shell).to receive(:ssh_path_prefix).and_return(Settings.send(:build_gitlab_shell_ssh_path_prefix))
stub_url(['ssh://', config.ssh_user, '@', config.host, ':2222/gitlab-org/gitlab-foss.git'].join(''))
- expect(subject).to eq([namespace_project_path('gitlab-org', 'gitlab-foss'), namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash')])
+ aggregate_failures do
+ expect(subject.web).to eq(namespace_project_path('gitlab-org', 'gitlab-foss'))
+ expect(subject.tree).to eq(namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash'))
+ expect(subject.compare).to be_nil
+ end
end
it 'detects ssh on non-standard port without a username' do
@@ -47,21 +59,33 @@ RSpec.describe SubmoduleHelper do
allow(Gitlab.config.gitlab_shell).to receive(:ssh_user).and_return('')
allow(Gitlab.config.gitlab_shell).to receive(:ssh_path_prefix).and_return(Settings.send(:build_gitlab_shell_ssh_path_prefix))
stub_url(['ssh://', config.host, ':2222/gitlab-org/gitlab-foss.git'].join(''))
- expect(subject).to eq([namespace_project_path('gitlab-org', 'gitlab-foss'), namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash')])
+ aggregate_failures do
+ expect(subject.web).to eq(namespace_project_path('gitlab-org', 'gitlab-foss'))
+ expect(subject.tree).to eq(namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash'))
+ expect(subject.compare).to be_nil
+ end
end
it 'detects http on standard port' do
allow(Gitlab.config.gitlab).to receive(:port).and_return(80)
allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url))
stub_url(['http://', config.host, '/gitlab-org/gitlab-foss.git'].join(''))
- expect(subject).to eq([namespace_project_path('gitlab-org', 'gitlab-foss'), namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash')])
+ aggregate_failures do
+ expect(subject.web).to eq(namespace_project_path('gitlab-org', 'gitlab-foss'))
+ expect(subject.tree).to eq(namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash'))
+ expect(subject.compare).to be_nil
+ end
end
it 'detects http on non-standard port' do
allow(Gitlab.config.gitlab).to receive(:port).and_return(3000)
allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url))
stub_url(['http://', config.host, ':3000/gitlab-org/gitlab-foss.git'].join(''))
- expect(subject).to eq([namespace_project_path('gitlab-org', 'gitlab-foss'), namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash')])
+ aggregate_failures do
+ expect(subject.web).to eq(namespace_project_path('gitlab-org', 'gitlab-foss'))
+ expect(subject.tree).to eq(namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash'))
+ expect(subject.compare).to be_nil
+ end
end
it 'works with relative_url_root' do
@@ -69,7 +93,11 @@ RSpec.describe SubmoduleHelper do
allow(Gitlab.config.gitlab).to receive(:relative_url_root).and_return('/gitlab/root')
allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url))
stub_url(['http://', config.host, '/gitlab/root/gitlab-org/gitlab-foss.git'].join(''))
- expect(subject).to eq([namespace_project_path('gitlab-org', 'gitlab-foss'), namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash')])
+ aggregate_failures do
+ expect(subject.web).to eq(namespace_project_path('gitlab-org', 'gitlab-foss'))
+ expect(subject.tree).to eq(namespace_project_tree_path('gitlab-org', 'gitlab-foss', 'hash'))
+ expect(subject.compare).to be_nil
+ end
end
it 'works with subgroups' do
@@ -77,61 +105,105 @@ RSpec.describe SubmoduleHelper do
allow(Gitlab.config.gitlab).to receive(:relative_url_root).and_return('/gitlab/root')
allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url))
stub_url(['http://', config.host, '/gitlab/root/gitlab-org/sub/gitlab-foss.git'].join(''))
- expect(subject).to eq([namespace_project_path('gitlab-org/sub', 'gitlab-foss'), namespace_project_tree_path('gitlab-org/sub', 'gitlab-foss', 'hash')])
+ aggregate_failures do
+ expect(subject.web).to eq(namespace_project_path('gitlab-org/sub', 'gitlab-foss'))
+ expect(subject.tree).to eq(namespace_project_tree_path('gitlab-org/sub', 'gitlab-foss', 'hash'))
+ expect(subject.compare).to be_nil
+ end
end
end
context 'submodule on gist.github.com' do
it 'detects ssh' do
stub_url('git@gist.github.com:gitlab-org/gitlab-foss.git')
- is_expected.to eq(['https://gist.github.com/gitlab-org/gitlab-foss', 'https://gist.github.com/gitlab-org/gitlab-foss/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gist.github.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gist.github.com/gitlab-org/gitlab-foss/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'detects http' do
stub_url('http://gist.github.com/gitlab-org/gitlab-foss.git')
- is_expected.to eq(['https://gist.github.com/gitlab-org/gitlab-foss', 'https://gist.github.com/gitlab-org/gitlab-foss/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gist.github.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gist.github.com/gitlab-org/gitlab-foss/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'detects https' do
stub_url('https://gist.github.com/gitlab-org/gitlab-foss.git')
- is_expected.to eq(['https://gist.github.com/gitlab-org/gitlab-foss', 'https://gist.github.com/gitlab-org/gitlab-foss/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gist.github.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gist.github.com/gitlab-org/gitlab-foss/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'handles urls with no .git on the end' do
stub_url('http://gist.github.com/gitlab-org/gitlab-foss')
- is_expected.to eq(['https://gist.github.com/gitlab-org/gitlab-foss', 'https://gist.github.com/gitlab-org/gitlab-foss/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gist.github.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gist.github.com/gitlab-org/gitlab-foss/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'returns original with non-standard url' do
stub_url('http://gist.github.com/another/gitlab-org/gitlab-foss.git')
- is_expected.to eq([repo.submodule_url_for, nil])
+ aggregate_failures do
+ expect(subject.web).to eq(repo.submodule_url_for)
+ expect(subject.tree).to be_nil
+ expect(subject.compare).to be_nil
+ end
end
end
context 'submodule on github.com' do
it 'detects ssh' do
stub_url('git@github.com:gitlab-org/gitlab-foss.git')
- expect(subject).to eq(['https://github.com/gitlab-org/gitlab-foss', 'https://github.com/gitlab-org/gitlab-foss/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://github.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://github.com/gitlab-org/gitlab-foss/tree/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'detects http' do
stub_url('http://github.com/gitlab-org/gitlab-foss.git')
- expect(subject).to eq(['https://github.com/gitlab-org/gitlab-foss', 'https://github.com/gitlab-org/gitlab-foss/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://github.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://github.com/gitlab-org/gitlab-foss/tree/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'detects https' do
stub_url('https://github.com/gitlab-org/gitlab-foss.git')
- expect(subject).to eq(['https://github.com/gitlab-org/gitlab-foss', 'https://github.com/gitlab-org/gitlab-foss/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://github.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://github.com/gitlab-org/gitlab-foss/tree/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'handles urls with no .git on the end' do
stub_url('http://github.com/gitlab-org/gitlab-foss')
- expect(subject).to eq(['https://github.com/gitlab-org/gitlab-foss', 'https://github.com/gitlab-org/gitlab-foss/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://github.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://github.com/gitlab-org/gitlab-foss/tree/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'returns original with non-standard url' do
stub_url('http://github.com/another/gitlab-org/gitlab-foss.git')
- expect(subject).to eq([repo.submodule_url_for, nil])
+ aggregate_failures do
+ expect(subject.web).to eq(repo.submodule_url_for)
+ expect(subject.tree).to be_nil
+ expect(subject.compare).to be_nil
+ end
end
end
@@ -143,39 +215,67 @@ RSpec.describe SubmoduleHelper do
allow(repo).to receive(:project).and_return(project)
stub_url('./')
- expect(subject).to eq(["/master-project/#{project.path}", "/master-project/#{project.path}/-/tree/hash"])
+ aggregate_failures do
+ expect(subject.web).to eq("/master-project/#{project.path}")
+ expect(subject.tree).to eq("/master-project/#{project.path}/-/tree/hash")
+ expect(subject.compare).to be_nil
+ end
end
end
context 'submodule on gitlab.com' do
it 'detects ssh' do
stub_url('git@gitlab.com:gitlab-org/gitlab-foss.git')
- expect(subject).to eq(['https://gitlab.com/gitlab-org/gitlab-foss', 'https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gitlab.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'detects http' do
stub_url('http://gitlab.com/gitlab-org/gitlab-foss.git')
- expect(subject).to eq(['https://gitlab.com/gitlab-org/gitlab-foss', 'https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gitlab.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'detects https' do
stub_url('https://gitlab.com/gitlab-org/gitlab-foss.git')
- expect(subject).to eq(['https://gitlab.com/gitlab-org/gitlab-foss', 'https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gitlab.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'handles urls with no .git on the end' do
stub_url('http://gitlab.com/gitlab-org/gitlab-foss')
- expect(subject).to eq(['https://gitlab.com/gitlab-org/gitlab-foss', 'https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gitlab.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'handles urls with trailing whitespace' do
stub_url('http://gitlab.com/gitlab-org/gitlab-foss.git ')
- expect(subject).to eq(['https://gitlab.com/gitlab-org/gitlab-foss', 'https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gitlab.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash')
+ expect(subject.compare).to be_nil
+ end
end
it 'returns original with non-standard url' do
stub_url('http://gitlab.com/another/gitlab-org/gitlab-foss.git')
- expect(subject).to eq([repo.submodule_url_for, nil])
+ aggregate_failures do
+ expect(subject.web).to eq(repo.submodule_url_for)
+ expect(subject.tree).to be_nil
+ expect(subject.compare).to be_nil
+ end
end
end
@@ -183,25 +283,29 @@ RSpec.describe SubmoduleHelper do
it 'sanitizes unsupported protocols' do
stub_url('javascript:alert("XSS");')
- expect(subject).to eq([nil, nil])
+ expect(subject).to be_nil
end
it 'sanitizes unsupported protocols disguised as a repository URL' do
stub_url('javascript:alert("XSS");foo/bar.git')
- expect(subject).to eq([nil, nil])
+ expect(subject).to be_nil
end
it 'sanitizes invalid URL with extended ASCII' do
stub_url('é')
- expect(subject).to eq([nil, nil])
+ expect(subject).to be_nil
end
it 'returns original' do
stub_url('http://mygitserver.com/gitlab-org/gitlab-foss')
- expect(subject).to eq([repo.submodule_url_for, nil])
+ aggregate_failures do
+ expect(subject.web).to eq(repo.submodule_url_for)
+ expect(subject.tree).to be_nil
+ expect(subject.compare).to be_nil
+ end
end
end
@@ -214,7 +318,11 @@ RSpec.describe SubmoduleHelper do
stub_url(relative_path)
result = subject
- expect(result).to eq([expected_path, "#{expected_path}/-/tree/#{submodule_item.id}"])
+ aggregate_failures do
+ expect(result.web).to eq(expected_path)
+ expect(result.tree).to eq("#{expected_path}/-/tree/#{submodule_item.id}")
+ expect(result.compare).to be_nil
+ end
end
it 'handles project under same group' do
@@ -235,7 +343,7 @@ RSpec.describe SubmoduleHelper do
result = subject
- expect(result).to eq([nil, nil])
+ expect(result).to be_nil
end
end
@@ -245,7 +353,7 @@ RSpec.describe SubmoduleHelper do
result = subject
- expect(result).to eq([nil, nil])
+ expect(result).to be_nil
end
end
@@ -289,7 +397,7 @@ RSpec.describe SubmoduleHelper do
end
it 'returns no links' do
- expect(subject).to eq([nil, nil])
+ expect(subject).to be_nil
end
end
end
diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb
index 307479744ef..97b6802dde9 100644
--- a/spec/helpers/tree_helper_spec.rb
+++ b/spec/helpers/tree_helper_spec.rb
@@ -156,21 +156,36 @@ RSpec.describe TreeHelper do
end
describe '#vue_file_list_data' do
- before do
- allow(helper).to receive(:current_user).and_return(nil)
- end
-
it 'returns a list of attributes related to the project' do
expect(helper.vue_file_list_data(project, sha)).to include(
- can_push_code: nil,
- fork_path: nil,
- escaped_ref: sha,
- ref: sha,
project_path: project.full_path,
project_short_path: project.path,
+ ref: sha,
+ escaped_ref: sha,
full_name: project.name_with_namespace
)
end
+ end
+
+ describe '#vue_ide_link_data' do
+ before do
+ allow(helper).to receive(:current_user).and_return(nil)
+ allow(helper).to receive(:can_collaborate_with_project?).and_return(true)
+ allow(helper).to receive(:can?).and_return(true)
+ end
+
+ subject { helper.vue_ide_link_data(project, sha) }
+
+ it 'returns a list of attributes related to the project' do
+ expect(subject).to include(
+ ide_base_path: project.full_path,
+ needs_to_fork: false,
+ show_web_ide_button: true,
+ show_gitpod_button: false,
+ gitpod_url: "",
+ gitpod_enabled: nil
+ )
+ end
context 'user does not have write access but a personal fork exists' do
include ProjectForksHelper
@@ -185,9 +200,9 @@ RSpec.describe TreeHelper do
allow(helper).to receive(:current_user).and_return(user)
end
- it 'includes fork_path too' do
- expect(helper.vue_file_list_data(project, sha)).to include(
- fork_path: forked_project.full_path
+ it 'includes ide_base_path: forked_project.full_path' do
+ expect(subject).to include(
+ ide_base_path: forked_project.full_path
)
end
end
@@ -201,9 +216,54 @@ RSpec.describe TreeHelper do
allow(helper).to receive(:current_user).and_return(user)
end
- it 'includes can_push_code: true' do
- expect(helper.vue_file_list_data(project, sha)).to include(
- can_push_code: "true"
+ it 'includes ide_base_path: project.full_path' do
+ expect(subject).to include(
+ ide_base_path: project.full_path
+ )
+ end
+ end
+
+ context 'gitpod feature is enabled' do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ stub_feature_flags(gitpod: true)
+ allow(Gitlab::CurrentSettings)
+ .to receive(:gitpod_enabled)
+ .and_return(true)
+
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ it 'has show_gitpod_button: true' do
+ expect(subject).to include(
+ show_gitpod_button: true
+ )
+ end
+
+ it 'has gitpod_enabled: true when user has enabled gitpod' do
+ user.gitpod_enabled = true
+
+ expect(subject).to include(
+ gitpod_enabled: true
+ )
+ end
+
+ it 'has gitpod_enabled: false when user has not enabled gitpod' do
+ user.gitpod_enabled = false
+
+ expect(subject).to include(
+ gitpod_enabled: false
+ )
+ end
+
+ it 'has show_gitpod_button: false when web ide button is not shown' do
+ allow(helper).to receive(:can_collaborate_with_project?).and_return(false)
+ allow(helper).to receive(:can?).and_return(false)
+
+ expect(subject).to include(
+ show_web_ide_button: false,
+ show_gitpod_button: false
)
end
end
diff --git a/spec/helpers/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb
index 6f1f358af83..a42be3c87fb 100644
--- a/spec/helpers/user_callouts_helper_spec.rb
+++ b/spec/helpers/user_callouts_helper_spec.rb
@@ -81,6 +81,26 @@ RSpec.describe UserCalloutsHelper do
end
end
+ describe '.show_service_templates_deprecated?' do
+ subject { helper.show_service_templates_deprecated? }
+
+ context 'when user has not dismissed' do
+ before do
+ allow(helper).to receive(:user_dismissed?).with(described_class::SERVICE_TEMPLATES_DEPRECATED) { false }
+ end
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user dismissed' do
+ before do
+ allow(helper).to receive(:user_dismissed?).with(described_class::SERVICE_TEMPLATES_DEPRECATED) { true }
+ end
+
+ it { is_expected.to be false }
+ end
+ end
+
describe '.show_customize_homepage_banner?' do
let(:customize_homepage) { true }
diff --git a/spec/helpers/whats_new_helper_spec.rb b/spec/helpers/whats_new_helper_spec.rb
new file mode 100644
index 00000000000..db880163454
--- /dev/null
+++ b/spec/helpers/whats_new_helper_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WhatsNewHelper do
+ describe '#whats_new_most_recent_release_items' do
+ let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
+
+ it 'returns json from the most recent file' do
+ allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
+
+ expect(helper.whats_new_most_recent_release_items).to include({ title: "bright and sunshinin' day" }.to_json)
+ end
+
+ it 'fails gracefully and logs an error' do
+ allow(YAML).to receive(:load_file).and_raise
+
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ expect(helper.whats_new_most_recent_release_items).to eq(''.to_json)
+ end
+ end
+end
diff --git a/spec/helpers/wiki_helper_spec.rb b/spec/helpers/wiki_helper_spec.rb
index 6c7172e6232..65a52412f8c 100644
--- a/spec/helpers/wiki_helper_spec.rb
+++ b/spec/helpers/wiki_helper_spec.rb
@@ -53,6 +53,22 @@ RSpec.describe WikiHelper do
end
end
+ describe '#wiki_attachment_upload_url' do
+ it 'returns the upload endpoint for project wikis' do
+ @wiki = build_stubbed(:project_wiki)
+
+ expect(helper.wiki_attachment_upload_url).to end_with("/api/v4/projects/#{@wiki.project.id}/wikis/attachments")
+ end
+
+ it 'raises an exception for unsupported wiki containers' do
+ @wiki = Wiki.new(User.new)
+
+ expect do
+ helper.wiki_attachment_upload_url
+ end.to raise_error(TypeError)
+ end
+ end
+
describe '#wiki_sort_controls' do
let(:wiki) { create(:project_wiki) }
let(:wiki_link) { helper.wiki_sort_controls(wiki, sort, direction) }
diff --git a/spec/initializers/carrierwave_patch_spec.rb b/spec/initializers/carrierwave_patch_spec.rb
index d577eca2ac7..c4a7bfa59c6 100644
--- a/spec/initializers/carrierwave_patch_spec.rb
+++ b/spec/initializers/carrierwave_patch_spec.rb
@@ -28,5 +28,17 @@ RSpec.describe 'CarrierWave::Storage::Fog::File' do
expect(subject.authenticated_url).to eq("https://sa.blob.core.windows.net/test_container/test_blob?token")
end
end
+
+ context 'with custom expire_at' do
+ it 'properly sets expires param' do
+ expire_at = 24.hours.from_now
+
+ expect_next_instance_of(Fog::Storage::AzureRM::File) do |file|
+ expect(file).to receive(:url).with(expire_at).and_call_original
+ end
+
+ expect(subject.authenticated_url(expire_at: expire_at)).to eq("https://sa.blob.core.windows.net/test_container/test_blob?token")
+ end
+ end
end
end
diff --git a/spec/initializers/remove_active_job_execute_callback_spec.rb b/spec/initializers/remove_active_job_execute_callback_spec.rb
new file mode 100644
index 00000000000..e88b859aa77
--- /dev/null
+++ b/spec/initializers/remove_active_job_execute_callback_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'ActiveJob execute callback' do
+ it 'is removed in test environment' do
+ expect(ActiveJob::Callbacks.singleton_class.__callbacks[:execute].send(:chain).size).to eq(0)
+ end
+end
diff --git a/spec/lib/api/helpers/packages_manager_clients_helpers_spec.rb b/spec/lib/api/helpers/packages_manager_clients_helpers_spec.rb
index 73b67f9e61c..3c40859da21 100644
--- a/spec/lib/api/helpers/packages_manager_clients_helpers_spec.rb
+++ b/spec/lib/api/helpers/packages_manager_clients_helpers_spec.rb
@@ -3,126 +3,87 @@
require 'spec_helper'
RSpec.describe API::Helpers::PackagesManagerClientsHelpers do
+ include HttpBasicAuthHelpers
+
let_it_be(:personal_access_token) { create(:personal_access_token) }
let_it_be(:username) { personal_access_token.user.username }
let_it_be(:helper) { Class.new.include(described_class).new }
let(:password) { personal_access_token.token }
- describe '#find_job_from_http_basic_auth' do
- let_it_be(:user) { personal_access_token.user }
-
- let(:job) { create(:ci_build, user: user, status: :running) }
- let(:password) { job.token }
- let(:headers) { { Authorization: basic_http_auth(username, password) } }
-
- subject { helper.find_job_from_http_basic_auth }
-
- before do
- allow(helper).to receive(:headers).and_return(headers&.with_indifferent_access)
- end
-
- context 'with a valid Authorization header' do
- it { is_expected.to eq job }
+ let(:env) do
+ {
+ 'rack.input' => ''
+ }
+ end
- context 'when the job is not running' do
- before do
- job.update!(status: :failed)
- end
+ let(:request) { ActionDispatch::Request.new(env) }
- it { is_expected.to be nil }
- end
- end
+ before do
+ allow(helper).to receive(:request).and_return(request)
+ end
+ shared_examples 'invalid auth header' do
context 'with an invalid Authorization header' do
- where(:headers) do
- [
- [{ Authorization: 'Invalid' }],
- [{}],
- [nil]
- ]
+ before do
+ env.merge!(build_auth_headers('Invalid'))
end
- with_them do
- it { is_expected.to be nil }
- end
- end
-
- context 'with an unknown Authorization header' do
- let(:password) { 'Unknown' }
-
it { is_expected.to be nil }
end
end
- describe '#find_deploy_token_from_http_basic_auth' do
- let_it_be(:deploy_token) { create(:deploy_token) }
- let(:token) { deploy_token.token }
- let(:headers) { { Authorization: basic_http_auth(deploy_token.username, token) } }
-
- subject { helper.find_deploy_token_from_http_basic_auth }
-
- before do
- allow(helper).to receive(:headers).and_return(headers&.with_indifferent_access)
- end
-
+ shared_examples 'valid auth header' do
context 'with a valid Authorization header' do
- it { is_expected.to eq deploy_token }
- end
-
- context 'with an invalid Authorization header' do
- where(:headers) do
- [
- [{ Authorization: 'Invalid' }],
- [{}],
- [nil]
- ]
+ before do
+ env.merge!(basic_auth_header(username, password))
end
- with_them do
+ context 'with an unknown password' do
+ let(:password) { 'Unknown' }
+
it { is_expected.to be nil }
end
- end
-
- context 'with an invalid token' do
- let(:token) { 'Unknown' }
- it { is_expected.to be nil }
+ it { is_expected.to eq expected_result }
end
end
- describe '#uploaded_package_file' do
- let_it_be(:params) { {} }
+ describe '#find_job_from_http_basic_auth' do
+ let_it_be(:user) { personal_access_token.user }
+ let(:job) { create(:ci_build, user: user, status: :running) }
+ let(:password) { job.token }
- subject { helper.uploaded_package_file }
+ subject { helper.find_job_from_http_basic_auth }
- before do
- allow(helper).to receive(:params).and_return(params)
+ it_behaves_like 'valid auth header' do
+ let(:expected_result) { job }
end
- context 'with valid uploaded package file' do
- let_it_be(:uploaded_file) { Object.new }
+ it_behaves_like 'invalid auth header'
+ context 'when the job is not running' do
before do
- allow(UploadedFile).to receive(:from_params).and_return(uploaded_file)
+ job.update!(status: :failed)
end
- it { is_expected.to be uploaded_file }
+ it_behaves_like 'valid auth header' do
+ let(:expected_result) { nil }
+ end
end
+ end
- context 'with invalid uploaded package file' do
- before do
- allow(UploadedFile).to receive(:from_params).and_return(nil)
- end
+ describe '#find_deploy_token_from_http_basic_auth' do
+ let_it_be(:deploy_token) { create(:deploy_token) }
+ let(:token) { deploy_token.token }
+ let(:username) { deploy_token.username }
+ let(:password) { token }
- it 'fails with bad_request!' do
- expect(helper).to receive(:bad_request!)
+ subject { helper.find_deploy_token_from_http_basic_auth }
- expect(subject).to be nil
- end
+ it_behaves_like 'valid auth header' do
+ let(:expected_result) { deploy_token }
end
- end
- def basic_http_auth(username, password)
- ActionController::HttpAuthentication::Basic.encode_credentials(username, password)
+ it_behaves_like 'invalid auth header'
end
end
diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb
index d0fe9163c6e..51a45dff6a4 100644
--- a/spec/lib/api/helpers_spec.rb
+++ b/spec/lib/api/helpers_spec.rb
@@ -183,12 +183,68 @@ RSpec.describe API::Helpers do
end
it "logs an exception" do
- expect(Rails.logger).to receive(:warn).with(/Tracking event failed/)
+ expect(Gitlab::AppLogger).to receive(:warn).with(/Tracking event failed/)
subject.track_event('my_event', category: nil)
end
end
+ describe '#increment_unique_values' do
+ let(:value) { '9f302fea-f828-4ca9-aef4-e10bd723c0b3' }
+ let(:event_name) { 'my_event' }
+ let(:unknown_event) { 'unknown' }
+ let(:feature) { "usage_data_#{event_name}" }
+
+ context 'with feature enabled' do
+ before do
+ stub_feature_flags(feature => true)
+ end
+
+ it 'tracks redis hll event' do
+ stub_application_setting(usage_ping_enabled: true)
+
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(value, event_name)
+
+ subject.increment_unique_values(event_name, value)
+ end
+
+ it 'does not track event usage ping is not enabled' do
+ stub_application_setting(usage_ping_enabled: false)
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ subject.increment_unique_values(event_name, value)
+ end
+
+ it 'logs an exception for unknown event' do
+ stub_application_setting(usage_ping_enabled: true)
+
+ expect(Gitlab::AppLogger).to receive(:warn).with("Redis tracking event failed for event: #{unknown_event}, message: Unknown event #{unknown_event}")
+
+ subject.increment_unique_values(unknown_event, value)
+ end
+
+ it 'does not track event for nil values' do
+ stub_application_setting(usage_ping_enabled: true)
+
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ subject.increment_unique_values(unknown_event, nil)
+ end
+ end
+
+ context 'with feature disabled' do
+ before do
+ stub_feature_flags(feature => false)
+ end
+
+ it 'does not track event' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ subject.increment_unique_values(event_name, value)
+ end
+ end
+ end
+
describe '#order_options_with_tie_breaker' do
subject { Class.new.include(described_class).new.order_options_with_tie_breaker }
diff --git a/spec/lib/atlassian/jira_connect/client_spec.rb b/spec/lib/atlassian/jira_connect/client_spec.rb
new file mode 100644
index 00000000000..40ffec21b26
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/client_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Client do
+ include StubRequests
+
+ subject { described_class.new('https://gitlab-test.atlassian.net', 'sample_secret') }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ describe '#store_dev_info' do
+ it "calls the API with auth headers" do
+ expected_jwt = Atlassian::Jwt.encode(
+ Atlassian::Jwt.build_claims(
+ Atlassian::JiraConnect.app_key,
+ '/rest/devinfo/0.10/bulk',
+ 'POST'
+ ),
+ 'sample_secret'
+ )
+
+ stub_full_request('https://gitlab-test.atlassian.net/rest/devinfo/0.10/bulk', method: :post)
+ .with(
+ headers: {
+ 'Authorization' => "JWT #{expected_jwt}",
+ 'Content-Type' => 'application/json'
+ }
+ )
+
+ subject.store_dev_info(project: create(:project))
+ end
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/serializers/author_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/author_entity_spec.rb
new file mode 100644
index 00000000000..f31cf929244
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/serializers/author_entity_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Serializers::AuthorEntity do
+ subject { described_class.represent(user).as_json }
+
+ context 'when object is a User model' do
+ let(:user) { build_stubbed(:user) }
+
+ it 'exposes all fields' do
+ expect(subject.keys).to contain_exactly(:name, :email, :username, :url, :avatar)
+ end
+ end
+
+ context 'when object is a CommitAuthor struct from a commit' do
+ let(:user) { Atlassian::JiraConnect::Serializers::CommitEntity::CommitAuthor.new('Full Name', 'user@example.com') }
+
+ it 'exposes name and email only' do
+ expect(subject.keys).to contain_exactly(:name, :email)
+ end
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb
new file mode 100644
index 00000000000..e69e2aae94c
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Serializers::BranchEntity do
+ let(:project) { create(:project, :repository) }
+ let(:branch) { project.repository.find_branch('improve/awesome') }
+
+ subject { described_class.represent(branch, project: project).as_json }
+
+ it 'sets the hash of the branch name as the id' do
+ expect(subject[:id]).to eq('bbfba9b197ace5da93d03382a7ce50081ae89d99faac1f2326566941288871ce')
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb
new file mode 100644
index 00000000000..23ba1770827
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Serializers::RepositoryEntity do
+ subject do
+ project = create(:project, :repository)
+ commits = [project.commit]
+ branches = [project.repository.find_branch('master')]
+ merge_requests = [create(:merge_request, source_project: project, target_project: project)]
+
+ described_class.represent(
+ project,
+ commits: commits,
+ branches: branches,
+ merge_requests: merge_requests
+ ).to_json
+ end
+
+ it { is_expected.to match_schema('jira_connect/repository') }
+end
diff --git a/spec/lib/atlassian/jira_issue_key_extractor_spec.rb b/spec/lib/atlassian/jira_issue_key_extractor_spec.rb
new file mode 100644
index 00000000000..ce29e03f818
--- /dev/null
+++ b/spec/lib/atlassian/jira_issue_key_extractor_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Atlassian::JiraIssueKeyExtractor do
+ describe '.has_keys?' do
+ subject { described_class.has_keys?(string) }
+
+ context 'when string contains Jira issue keys' do
+ let(:string) { 'Test some string TEST-01 with keys' }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when string does not contain Jira issue keys' do
+ let(:string) { 'string with no jira issue keys' }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ describe '#issue_keys' do
+ subject { described_class.new('TEST-01 Some A-100 issue title OTHER-02 ABC!-1 that mentions Jira issue').issue_keys }
+
+ it 'returns all valid Jira issue keys' do
+ is_expected.to contain_exactly('TEST-01', 'OTHER-02')
+ end
+
+ context 'when multiple strings are passed in' do
+ subject { described_class.new('TEST-01 Some A-100', 'issue title OTHER', '-02 ABC!-1 that mentions Jira issue').issue_keys }
+
+ it 'returns all valid Jira issue keys in any of those string' do
+ is_expected.to contain_exactly('TEST-01')
+ end
+ end
+ end
+end
diff --git a/spec/lib/backup/artifacts_spec.rb b/spec/lib/backup/artifacts_spec.rb
new file mode 100644
index 00000000000..2a3f1949ba5
--- /dev/null
+++ b/spec/lib/backup/artifacts_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Backup::Artifacts do
+ let(:progress) { StringIO.new }
+
+ subject(:backup) { described_class.new(progress) }
+
+ describe '#initialize' do
+ it 'uses the correct upload dir' do
+ Dir.mktmpdir do |tmpdir|
+ allow(JobArtifactUploader).to receive(:root) { "#{tmpdir}" }
+
+ expect(backup.app_files_dir).to eq("#{tmpdir}")
+ end
+ end
+ end
+
+ describe '#dump' do
+ before do
+ allow(File).to receive(:realpath).with('/var/gitlab-artifacts').and_return('/var/gitlab-artifacts')
+ allow(File).to receive(:realpath).with('/var/gitlab-artifacts/..').and_return('/var')
+ allow(JobArtifactUploader).to receive(:root) { '/var/gitlab-artifacts' }
+ end
+
+ it 'uses the correct artifact dir' do
+ expect(backup.app_files_dir).to eq('/var/gitlab-artifacts')
+ end
+
+ it 'excludes tmp from backup tar' do
+ expect(backup).to receive(:tar).and_return('blabla-tar')
+ expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/gitlab-artifacts -cf - .), 'gzip -c -1'], any_args)
+ backup.dump
+ end
+ end
+end
diff --git a/spec/lib/backup/database_spec.rb b/spec/lib/backup/database_spec.rb
new file mode 100644
index 00000000000..fccd6db0018
--- /dev/null
+++ b/spec/lib/backup/database_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Backup::Database do
+ let(:progress) { StringIO.new }
+ let(:output) { progress.string }
+
+ describe '#restore' do
+ let(:cmd) { %W[#{Gem.ruby} -e $stdout.puts(1)] }
+ let(:data) { Rails.root.join("spec/fixtures/pages_empty.tar.gz").to_s }
+
+ subject { described_class.new(progress, filename: data) }
+
+ before do
+ allow(subject).to receive(:pg_restore_cmd).and_return(cmd)
+ end
+
+ context 'with an empty .gz file' do
+ let(:data) { Rails.root.join("spec/fixtures/pages_empty.tar.gz").to_s }
+
+ it 'returns successfully' do
+ expect(subject.restore).to eq([])
+
+ expect(output).to include("Restoring PostgreSQL database")
+ expect(output).to include("[DONE]")
+ expect(output).not_to include("ERRORS")
+ end
+ end
+
+ context 'with a corrupted .gz file' do
+ let(:data) { Rails.root.join("spec/fixtures/big-image.png").to_s }
+
+ it 'raises a backup error' do
+ expect { subject.restore }.to raise_error(Backup::Error)
+ end
+ end
+
+ context 'when the restore command prints errors' do
+ let(:visible_error) { "This is a test error\n" }
+ let(:noise) { "Table projects does not exist\nmust be owner of extension pg_trgm\n" }
+ let(:cmd) { %W[#{Gem.ruby} -e $stderr.write("#{noise}#{visible_error}")] }
+
+ it 'filters out noise from errors' do
+ expect(subject.restore).to eq([visible_error])
+ expect(output).to include("ERRORS")
+ expect(output).not_to include(noise)
+ expect(output).to include(visible_error)
+ end
+ end
+ end
+end
diff --git a/spec/lib/backup/files_spec.rb b/spec/lib/backup/files_spec.rb
index a7374b82ce0..c2dbaac7f15 100644
--- a/spec/lib/backup/files_spec.rb
+++ b/spec/lib/backup/files_spec.rb
@@ -14,6 +14,8 @@ RSpec.describe Backup::Files do
allow(File).to receive(:exist?).and_return(true)
allow(File).to receive(:realpath).with("/var/gitlab-registry").and_return("/var/gitlab-registry")
allow(File).to receive(:realpath).with("/var/gitlab-registry/..").and_return("/var")
+ allow(File).to receive(:realpath).with("/var/gitlab-pages").and_return("/var/gitlab-pages")
+ allow(File).to receive(:realpath).with("/var/gitlab-pages/..").and_return("/var")
allow_any_instance_of(String).to receive(:color) do |string, _color|
string
@@ -82,4 +84,48 @@ RSpec.describe Backup::Files do
end
end
end
+
+ describe '#dump' do
+ subject { described_class.new('pages', '/var/gitlab-pages', excludes: ['@pages.tmp']) }
+
+ before do
+ allow(subject).to receive(:run_pipeline!).and_return(true)
+ end
+
+ it 'raises no errors' do
+ expect { subject.dump }.not_to raise_error
+ end
+
+ it 'excludes tmp dirs from archive' do
+ expect(subject).to receive(:tar).and_return('blabla-tar')
+
+ expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args)
+ subject.dump
+ end
+
+ describe 'with STRATEGY=copy' do
+ before do
+ stub_env('STRATEGY', 'copy')
+ end
+
+ it 'excludes tmp dirs from rsync' do
+ allow(Gitlab.config.backup).to receive(:path) { '/var/gitlab-backup' }
+ allow(File).to receive(:realpath).with("/var/gitlab-backup").and_return("/var/gitlab-backup")
+
+ expect(Gitlab::Popen).to receive(:popen).with(%w(rsync -a --exclude=lost+found --exclude=/@pages.tmp /var/gitlab-pages /var/gitlab-backup)).and_return(['', 0])
+
+ subject.dump
+ end
+ end
+
+ describe '#exclude_dirs' do
+ it 'prepends a leading dot slash to tar excludes' do
+ expect(subject.exclude_dirs(:tar)).to eq(['--exclude=lost+found', '--exclude=./@pages.tmp'])
+ end
+
+ it 'prepends a leading slash to rsync excludes' do
+ expect(subject.exclude_dirs(:rsync)).to eq(['--exclude=lost+found', '--exclude=/@pages.tmp'])
+ end
+ end
+ end
end
diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb
index 38a5c30506b..feaca6164eb 100644
--- a/spec/lib/backup/manager_spec.rb
+++ b/spec/lib/backup/manager_spec.rb
@@ -416,5 +416,28 @@ RSpec.describe Backup::Manager do
subject.upload
end
end
+
+ context 'with AzureRM provider' do
+ before do
+ stub_backup_setting(
+ upload: {
+ connection: {
+ provider: 'AzureRM',
+ azure_storage_account_name: 'test-access-id',
+ azure_storage_access_key: 'secret'
+ },
+ remote_directory: 'directory',
+ multipart_chunk_size: nil,
+ encryption: nil,
+ encryption_key: nil,
+ storage_class: nil
+ }
+ )
+ end
+
+ it 'loads the provider' do
+ expect { subject.upload }.not_to raise_error
+ end
+ end
end
end
diff --git a/spec/lib/backup/pages_spec.rb b/spec/lib/backup/pages_spec.rb
new file mode 100644
index 00000000000..59df4d1adf7
--- /dev/null
+++ b/spec/lib/backup/pages_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Backup::Pages do
+ let(:progress) { StringIO.new }
+
+ subject { described_class.new(progress) }
+
+ before do
+ allow(File).to receive(:realpath).with("/var/gitlab-pages").and_return("/var/gitlab-pages")
+ allow(File).to receive(:realpath).with("/var/gitlab-pages/..").and_return("/var")
+ end
+
+ describe '#dump' do
+ it 'uses the correct pages dir' do
+ allow(Gitlab.config.pages).to receive(:path) { '/var/gitlab-pages' }
+
+ expect(subject.app_files_dir).to eq('/var/gitlab-pages')
+ end
+
+ it 'excludes tmp from backup tar' do
+ allow(Gitlab.config.pages).to receive(:path) { '/var/gitlab-pages' }
+
+ expect(subject).to receive(:tar).and_return('blabla-tar')
+ expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args)
+ subject.dump
+ end
+ end
+end
diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb
index c4ad239f9d7..718f38f9452 100644
--- a/spec/lib/backup/repository_spec.rb
+++ b/spec/lib/backup/repository_spec.rb
@@ -47,11 +47,23 @@ RSpec.describe Backup::Repository do
end
it 'project query raises an error' do
- allow(Project).to receive(:find_each).and_raise(ActiveRecord::StatementTimeout)
+ allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(ActiveRecord::StatementTimeout)
end
end
+
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
+ end.count
+
+ create_list(:project, 2, :wiki_repo)
+
+ expect do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
+ end.not_to exceed_query_limit(control_count)
+ end
end
[4, 10].each do |max_storage_concurrency|
@@ -89,7 +101,7 @@ RSpec.describe Backup::Repository do
end
it 'project query raises an error' do
- allow(Project).to receive_message_chain('for_repository_storage.find_each').and_raise(ActiveRecord::StatementTimeout)
+ allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(ActiveRecord::StatementTimeout)
end
@@ -102,6 +114,18 @@ RSpec.describe Backup::Repository do
end
end
end
+
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
+ end.count
+
+ create_list(:project, 2, :wiki_repo)
+
+ expect do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
+ end.not_to exceed_query_limit(control_count)
+ end
end
end
end
diff --git a/spec/lib/backup/uploads_spec.rb b/spec/lib/backup/uploads_spec.rb
index 7c2d715b580..678b670db34 100644
--- a/spec/lib/backup/uploads_spec.rb
+++ b/spec/lib/backup/uploads_spec.rb
@@ -18,4 +18,22 @@ RSpec.describe Backup::Uploads do
end
end
end
+
+ describe '#dump' do
+ before do
+ allow(File).to receive(:realpath).with('/var/uploads').and_return('/var/uploads')
+ allow(File).to receive(:realpath).with('/var/uploads/..').and_return('/var')
+ allow(Gitlab.config.uploads).to receive(:storage_path) { '/var' }
+ end
+
+ it 'uses the correct upload dir' do
+ expect(backup.app_files_dir).to eq('/var/uploads')
+ end
+
+ it 'excludes tmp from backup tar' do
+ expect(backup).to receive(:tar).and_return('blabla-tar')
+ expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/uploads -cf - .), 'gzip -c -1'], any_args)
+ backup.dump
+ end
+ end
end
diff --git a/spec/lib/banzai/filter/alert_reference_filter_spec.rb b/spec/lib/banzai/filter/alert_reference_filter_spec.rb
new file mode 100644
index 00000000000..c57a8a7321c
--- /dev/null
+++ b/spec/lib/banzai/filter/alert_reference_filter_spec.rb
@@ -0,0 +1,223 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::AlertReferenceFilter do
+ include FilterSpecHelper
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project) }
+ let_it_be(:reference) { alert.to_reference }
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Alert #{reference}</#{elem}>"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'internal reference' do
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Alert (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(reference)}</a>\.\)})
+ end
+
+ it 'ignores invalid alert IDs' do
+ exp = act = "Alert #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'includes a title attribute' do
+ doc = reference_filter("Alert #{reference}")
+
+ expect(doc.css('a').first.attr('title')).to eq alert.title
+ end
+
+ it 'escapes the title attribute' do
+ allow(alert).to receive(:title).and_return(%{"></a>whatever<a title="})
+ doc = reference_filter("Alert #{reference}")
+
+ expect(doc.text).to eq "Alert #{reference}"
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Alert #{reference}")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-alert has-tooltip'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Alert #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-project')
+ expect(link.attr('data-project')).to eq project.id.to_s
+ end
+
+ it 'includes a data-alert attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-alert')
+ expect(link.attr('data-alert')).to eq alert.id.to_s
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Alert #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq urls.details_project_alert_management_url(project, alert.iid, only_path: true)
+ end
+ end
+
+ context 'cross-project / cross-namespace complete reference' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project2) }
+ let_it_be(:reference) { "#{project2.full_path}^alert##{alert.iid}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql(reference)
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text).to eql("See (#{reference}.)")
+ end
+
+ it 'ignores invalid alert IDs on the referenced project' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project / same-namespace complete reference' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project2) }
+ let_it_be(:reference) { "#{project2.full_path}^alert##{alert.iid}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{project2.path}^alert##{alert.iid}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}^alert##{alert.iid}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{project2.path}^alert##{alert.iid}.)")
+
+ expect(doc.text).to eql("See (#{project2.path}^alert##{alert.iid}.)")
+ end
+
+ it 'ignores invalid alert IDs on the referenced project' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project shorthand reference' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project2) }
+ let_it_be(:reference) { "#{project2.path}^alert##{alert.iid}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{project2.path}^alert##{alert.iid}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}^alert##{alert.iid}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{project2.path}^alert##{alert.iid}.)")
+
+ expect(doc.text).to eql("See (#{project2.path}^alert##{alert.iid}.)")
+ end
+
+ it 'ignores invalid alert IDs on the referenced project' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project URL reference' do
+ let_it_be(:namespace) { create(:namespace, name: 'cross-reference') }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project2) }
+ let_it_be(:reference) { alert.details_url }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(alert.to_reference(project))}</a>\.\)})
+ end
+
+ it 'ignores invalid alert IDs on the referenced project' do
+ act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to match(%r{<a.+>#{Regexp.escape(invalidate_reference(reference))}</a>})
+ end
+ end
+
+ context 'group context' do
+ let_it_be(:group) { create(:group) }
+
+ it 'links to a valid reference' do
+ reference = "#{project.full_path}^alert##{alert.iid}"
+ result = reference_filter("See #{reference}", { project: nil, group: group } )
+
+ expect(result.css('a').first.attr('href')).to eq(alert.details_url)
+ end
+
+ it 'ignores internal references' do
+ exp = act = "See ^alert##{alert.iid}"
+
+ expect(reference_filter(act, project: nil, group: group).to_html).to eq exp
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
index 7d8fb183dbb..e7b6c910b8a 100644
--- a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
@@ -208,4 +208,47 @@ RSpec.describe Banzai::Filter::ExternalIssueReferenceFilter do
end
end
end
+
+ context "ewm project" do
+ let_it_be(:project) { create(:ewm_project) }
+
+ before do
+ project.update!(issues_enabled: false)
+ end
+
+ context "rtcwi keyword" do
+ let(:issue) { ExternalIssue.new("rtcwi 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "workitem keyword" do
+ let(:issue) { ExternalIssue.new("workitem 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "defect keyword" do
+ let(:issue) { ExternalIssue.new("defect 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "task keyword" do
+ let(:issue) { ExternalIssue.new("task 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "bug keyword" do
+ let(:issue) { ExternalIssue.new("bug 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+ end
end
diff --git a/spec/lib/banzai/filter/inline_metrics_filter_spec.rb b/spec/lib/banzai/filter/inline_metrics_filter_spec.rb
index 9b0b95b9da2..cdebd886b16 100644
--- a/spec/lib/banzai/filter/inline_metrics_filter_spec.rb
+++ b/spec/lib/banzai/filter/inline_metrics_filter_spec.rb
@@ -5,25 +5,68 @@ require 'spec_helper'
RSpec.describe Banzai::Filter::InlineMetricsFilter do
include FilterSpecHelper
- let(:params) { ['foo', 'bar', 12] }
- let(:query_params) { {} }
-
- let(:trigger_url) { urls.metrics_namespace_project_environment_url(*params, query_params) }
+ let(:environment_id) { 12 }
let(:dashboard_url) { urls.metrics_dashboard_namespace_project_environment_url(*params, **query_params, embedded: true) }
- it_behaves_like 'a metrics embed filter'
+ let(:query_params) do
+ {
+ dashboard: 'config/prometheus/common_metrics.yml',
+ group: 'System metrics (Kubernetes)',
+ title: 'Core Usage (Pod Average)',
+ y_label: 'Cores per Pod'
+ }
+ end
+
+ context 'with /-/environments/:environment_id/metrics URL' do
+ let(:params) { ['group', 'project', environment_id] }
+ let(:trigger_url) { urls.metrics_namespace_project_environment_url(*params, **query_params) }
+
+ context 'with no query params' do
+ let(:query_params) { {} }
+
+ it_behaves_like 'a metrics embed filter'
+ end
+
+ context 'with query params' do
+ it_behaves_like 'a metrics embed filter'
+ end
+ end
- context 'with query params specified' do
- let(:query_params) do
- {
- dashboard: 'config/prometheus/common_metrics.yml',
- group: 'System metrics (Kubernetes)',
- title: 'Core Usage (Pod Average)',
- y_label: 'Cores per Pod'
- }
+ context 'with /-/metrics?environment=:environment_id URL' do
+ let(:params) { %w(group project) }
+ let(:trigger_url) { urls.namespace_project_metrics_dashboard_url(*params, **query_params) }
+ let(:dashboard_url) do
+ urls.metrics_dashboard_namespace_project_environment_url(
+ *params.append(environment_id),
+ **query_params.except(:environment),
+ embedded: true
+ )
end
- it_behaves_like 'a metrics embed filter'
+ context 'with query params' do
+ it_behaves_like 'a metrics embed filter' do
+ before do
+ query_params.merge!(environment: environment_id)
+ end
+ end
+ end
+
+ context 'with only environment in query params' do
+ let(:query_params) { { environment: environment_id } }
+
+ it_behaves_like 'a metrics embed filter'
+ end
+
+ context 'with no query params' do
+ let(:query_params) { {} }
+
+ it 'ignores metrics URL without environment parameter' do
+ input = %(<a href="#{trigger_url}">example</a>)
+ filtered_input = filter(input).to_s
+
+ expect(CGI.unescape_html(filtered_input)).to eq(input)
+ end
+ end
end
it 'leaves links to other dashboards unchanged' do
diff --git a/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb b/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb
index 5f66844f498..3c736b46131 100644
--- a/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb
+++ b/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb
@@ -22,6 +22,13 @@ RSpec.describe Banzai::Filter::InlineMetricsRedactorFilter do
it_behaves_like 'redacts the embed placeholder'
it_behaves_like 'retains the embed placeholder when applicable'
+ context 'with /-/metrics?environment=:environment_id URL' do
+ let(:url) { urls.project_metrics_dashboard_url(project, embedded: true, environment: 1) }
+
+ it_behaves_like 'redacts the embed placeholder'
+ it_behaves_like 'retains the embed placeholder when applicable'
+ end
+
context 'for a grafana dashboard' do
let(:url) { urls.project_grafana_api_metrics_dashboard_url(project, embedded: true) }
@@ -33,7 +40,7 @@ RSpec.describe Banzai::Filter::InlineMetricsRedactorFilter do
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, :project, projects: [project]) }
let(:params) { [project.namespace.path, project.path, cluster.id] }
let(:query_params) { { group: 'Cluster Health', title: 'CPU Usage', y_label: 'CPU (cores)' } }
- let(:url) { urls.metrics_dashboard_namespace_project_cluster_url(*params, **query_params) }
+ let(:url) { urls.metrics_dashboard_namespace_project_cluster_url(*params, **query_params, format: :json) }
context 'with user who can read cluster' do
it_behaves_like 'redacts the embed placeholder'
diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
index 0ad058675fe..447802d18a7 100644
--- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
@@ -40,10 +40,10 @@ RSpec.describe Banzai::Filter::IssueReferenceFilter do
end
context 'internal reference' do
- it_behaves_like 'a reference containing an element node'
-
let(:reference) { "##{issue.iid}" }
+ it_behaves_like 'a reference containing an element node'
+
it 'links to a valid reference' do
doc = reference_filter("Fixed #{reference}")
@@ -134,11 +134,11 @@ RSpec.describe Banzai::Filter::IssueReferenceFilter do
end
context 'cross-project / cross-namespace complete reference' do
- it_behaves_like 'a reference containing an element node'
-
- let(:project2) { create(:project, :public) }
- let(:issue) { create(:issue, project: project2) }
let(:reference) { "#{project2.full_path}##{issue.iid}" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public) }
+
+ it_behaves_like 'a reference containing an element node'
it 'ignores valid references when cross-reference project uses external tracker' do
expect_any_instance_of(described_class).to receive(:find_object)
@@ -182,13 +182,13 @@ RSpec.describe Banzai::Filter::IssueReferenceFilter do
end
context 'cross-project / same-namespace complete reference' do
- it_behaves_like 'a reference containing an element node'
-
- let(:namespace) { create(:namespace) }
- let(:project) { create(:project, :public, namespace: namespace) }
- let(:project2) { create(:project, :public, namespace: namespace) }
- let(:issue) { create(:issue, project: project2) }
let(:reference) { "#{project2.full_path}##{issue.iid}" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace) }
+
+ it_behaves_like 'a reference containing an element node'
it 'ignores valid references when cross-reference project uses external tracker' do
expect_any_instance_of(described_class).to receive(:find_object)
@@ -232,13 +232,13 @@ RSpec.describe Banzai::Filter::IssueReferenceFilter do
end
context 'cross-project shorthand reference' do
- it_behaves_like 'a reference containing an element node'
-
- let(:namespace) { create(:namespace) }
- let(:project) { create(:project, :public, namespace: namespace) }
- let(:project2) { create(:project, :public, namespace: namespace) }
- let(:issue) { create(:issue, project: project2) }
let(:reference) { "#{project2.path}##{issue.iid}" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace) }
+
+ it_behaves_like 'a reference containing an element node'
it 'ignores valid references when cross-reference project uses external tracker' do
expect_any_instance_of(described_class).to receive(:find_object)
@@ -282,12 +282,12 @@ RSpec.describe Banzai::Filter::IssueReferenceFilter do
end
context 'cross-project URL reference' do
- it_behaves_like 'a reference containing an element node'
-
- let(:namespace) { create(:namespace, name: 'cross-reference') }
- let(:project2) { create(:project, :public, namespace: namespace) }
- let(:issue) { create(:issue, project: project2) }
let(:reference) { issue_url + "#note_123" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace, name: 'cross-reference') }
+
+ it_behaves_like 'a reference containing an element node'
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
@@ -310,13 +310,13 @@ RSpec.describe Banzai::Filter::IssueReferenceFilter do
end
context 'cross-project reference in link href' do
- it_behaves_like 'a reference containing an element node'
-
- let(:namespace) { create(:namespace, name: 'cross-reference') }
- let(:project2) { create(:project, :public, namespace: namespace) }
- let(:issue) { create(:issue, project: project2) }
- let(:reference) { issue.to_reference(project) }
let(:reference_link) { %{<a href="#{reference}">Reference</a>} }
+ let(:reference) { issue.to_reference(project) }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace, name: 'cross-reference') }
+
+ it_behaves_like 'a reference containing an element node'
it 'links to a valid reference' do
doc = reference_filter("See #{reference_link}")
@@ -339,13 +339,13 @@ RSpec.describe Banzai::Filter::IssueReferenceFilter do
end
context 'cross-project URL in link href' do
- it_behaves_like 'a reference containing an element node'
-
- let(:namespace) { create(:namespace, name: 'cross-reference') }
- let(:project2) { create(:project, :public, namespace: namespace) }
- let(:issue) { create(:issue, project: project2) }
- let(:reference) { "#{issue_url + "#note_123"}" }
let(:reference_link) { %{<a href="#{reference}">Reference</a>} }
+ let(:reference) { "#{issue_url + "#note_123"}" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace, name: 'cross-reference') }
+
+ it_behaves_like 'a reference containing an element node'
it 'links to a valid reference' do
doc = reference_filter("See #{reference_link}")
diff --git a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
index 2d17855707f..6e90f4457fa 100644
--- a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
+++ b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
@@ -65,6 +65,11 @@ RSpec.describe Banzai::Filter::TableOfContentsFilter do
expect(doc.css('h1 a').first.attr('href')).to eq '#title-with-spaces'
end
+ it 'removes a product suffix' do
+ doc = filter(header(1, "Title with spaces (ULTIMATE)"))
+ expect(doc.css('h1 a').first.attr('href')).to eq '#title-with-spaces'
+ end
+
it 'appends a unique number to duplicates' do
doc = filter(header(1, 'One') + header(2, 'One'))
diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb
index d8de3e5cc11..b8baccf6658 100644
--- a/spec/lib/banzai/filter/user_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb
@@ -40,10 +40,10 @@ RSpec.describe Banzai::Filter::UserReferenceFilter do
end
context 'mentioning @all' do
- it_behaves_like 'a reference containing an element node'
-
let(:reference) { User.reference_prefix + 'all' }
+ it_behaves_like 'a reference containing an element node'
+
before do
project.add_developer(project.creator)
end
@@ -78,10 +78,10 @@ RSpec.describe Banzai::Filter::UserReferenceFilter do
end
context 'mentioning a group' do
- it_behaves_like 'a reference containing an element node'
-
- let(:group) { create(:group) }
let(:reference) { group.to_reference }
+ let(:group) { create(:group) }
+
+ it_behaves_like 'a reference containing an element node'
it 'links to the Group' do
doc = reference_filter("Hey #{reference}")
@@ -98,10 +98,10 @@ RSpec.describe Banzai::Filter::UserReferenceFilter do
end
context 'mentioning a nested group' do
- it_behaves_like 'a reference containing an element node'
-
- let(:group) { create(:group, :nested) }
let(:reference) { group.to_reference }
+ let(:group) { create(:group, :nested) }
+
+ it_behaves_like 'a reference containing an element node'
it 'links to the nested group' do
doc = reference_filter("Hey #{reference}")
diff --git a/spec/lib/banzai/reference_parser/alert_parser_spec.rb b/spec/lib/banzai/reference_parser/alert_parser_spec.rb
new file mode 100644
index 00000000000..0a9499fe6e4
--- /dev/null
+++ b/spec/lib/banzai/reference_parser/alert_parser_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::ReferenceParser::AlertParser do
+ include ReferenceParserHelpers
+
+ let(:project) { create(:project, :public) }
+ let(:user) { create(:user) }
+ let(:alert) { create(:alert_management_alert, project: project) }
+
+ subject { described_class.new(Banzai::RenderContext.new(project, user)) }
+
+ let(:link) { empty_html_link }
+
+ describe '#nodes_visible_to_user' do
+ context 'when the link has a data-issue attribute' do
+ before do
+ link['data-alert'] = alert.id.to_s
+ end
+
+ it_behaves_like "referenced feature visibility", "issues", "merge_requests" do
+ before do
+ project.add_developer(user) if enable_user?
+ end
+ end
+ end
+ end
+
+ describe '#referenced_by' do
+ describe 'when the link has a data-alert attribute' do
+ context 'using an existing alert ID' do
+ it 'returns an Array of alerts' do
+ link['data-alert'] = alert.id.to_s
+
+ expect(subject.referenced_by([link])).to eq([alert])
+ end
+ end
+
+ context 'using a non-existing alert ID' do
+ it 'returns an empty Array' do
+ link['data-alert'] = ''
+
+ expect(subject.referenced_by([link])).to eq([])
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/comment_spec.rb b/spec/lib/bitbucket_server/representation/comment_spec.rb
index b568789bd97..41cdaab7a00 100644
--- a/spec/lib/bitbucket_server/representation/comment_spec.rb
+++ b/spec/lib/bitbucket_server/representation/comment_spec.rb
@@ -13,7 +13,30 @@ RSpec.describe BitbucketServer::Representation::Comment do
end
describe '#author_username' do
- it { expect(subject.author_username).to eq('root' ) }
+ it 'returns username' do
+ expect(subject.author_username).to eq('username')
+ end
+
+ context 'when username is absent' do
+ before do
+ comment['comment']['author'].delete('username')
+ end
+
+ it 'returns slug' do
+ expect(subject.author_username).to eq('slug')
+ end
+ end
+
+ context 'when slug and username are absent' do
+ before do
+ comment['comment']['author'].delete('username')
+ comment['comment']['author'].delete('slug')
+ end
+
+ it 'returns displayName' do
+ expect(subject.author_username).to eq('root')
+ end
+ end
end
describe '#author_email' do
diff --git a/spec/lib/bitbucket_server/representation/pull_request_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_spec.rb
index a05d98f0d4a..d7b893e8081 100644
--- a/spec/lib/bitbucket_server/representation/pull_request_spec.rb
+++ b/spec/lib/bitbucket_server/representation/pull_request_spec.rb
@@ -15,6 +15,33 @@ RSpec.describe BitbucketServer::Representation::PullRequest do
it { expect(subject.author_email).to eq('joe.montana@49ers.com') }
end
+ describe '#author_username' do
+ it 'returns username' do
+ expect(subject.author_username).to eq('username')
+ end
+
+ context 'when username is absent' do
+ before do
+ sample_data['author']['user'].delete('username')
+ end
+
+ it 'returns slug' do
+ expect(subject.author_username).to eq('slug')
+ end
+ end
+
+ context 'when slug and username are absent' do
+ before do
+ sample_data['author']['user'].delete('username')
+ sample_data['author']['user'].delete('slug')
+ end
+
+ it 'returns displayName' do
+ expect(subject.author_username).to eq('displayName')
+ end
+ end
+ end
+
describe '#description' do
it { expect(subject.description).to eq('Test') }
end
diff --git a/spec/lib/constraints/jira_encoded_url_constrainer_spec.rb b/spec/lib/constraints/jira_encoded_url_constrainer_spec.rb
new file mode 100644
index 00000000000..70e649d35da
--- /dev/null
+++ b/spec/lib/constraints/jira_encoded_url_constrainer_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Constraints::JiraEncodedUrlConstrainer do
+ let(:namespace_id) { 'group' }
+ let(:project_id) { 'project' }
+ let(:path) { "/#{namespace_id}/#{project_id}" }
+ let(:request) { double(:request, path: path, params: { namespace_id: namespace_id, project_id: project_id }) }
+
+ describe '#matches?' do
+ subject { described_class.new.matches?(request) }
+
+ context 'when there is no /-/jira prefix and no encoded slash' do
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when tree path contains encoded slash' do
+ let(:path) { "/#{namespace_id}/#{project_id}/tree/folder-with-#{Gitlab::Jira::Dvcs::ENCODED_SLASH}" }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when path has /-/jira prefix' do
+ let(:path) { "/-/jira/#{namespace_id}/#{project_id}" }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when project_id has encoded slash' do
+ let(:project_id) { "sub_group#{Gitlab::Jira::Dvcs::ENCODED_SLASH}sub_project" }
+
+ it { is_expected.to eq(true) }
+ end
+ end
+end
diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb
index aa947329c33..4daf7375a40 100644
--- a/spec/lib/container_registry/client_spec.rb
+++ b/spec/lib/container_registry/client_spec.rb
@@ -289,4 +289,57 @@ RSpec.describe ContainerRegistry::Client do
end
end
end
+
+ describe '.supports_tag_delete?' do
+ let(:registry_enabled) { true }
+ let(:registry_api_url) { 'http://sandbox.local' }
+ let(:registry_tags_support_enabled) { true }
+ let(:is_on_dot_com) { false }
+
+ subject { described_class.supports_tag_delete? }
+
+ before do
+ allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
+ stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
+ stub_registry_tags_support(registry_tags_support_enabled)
+ end
+
+ context 'with the registry enabled' do
+ it { is_expected.to be true }
+
+ context 'without an api url' do
+ let(:registry_api_url) { '' }
+
+ it { is_expected.to be false }
+ end
+
+ context 'on .com' do
+ let(:is_on_dot_com) { true }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when registry server does not support tag deletion' do
+ let(:registry_tags_support_enabled) { false }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ context 'with the registry disabled' do
+ let(:registry_enabled) { false }
+
+ it { is_expected.to be false }
+ end
+
+ def stub_registry_tags_support(supported = true)
+ status_code = supported ? 200 : 404
+ stub_request(:options, "#{registry_api_url}/v2/name/tags/reference/tag")
+ .to_return(
+ status: status_code,
+ body: '',
+ headers: { 'Allow' => 'DELETE' }
+ )
+ end
+ end
end
diff --git a/spec/lib/gitlab/alert_management/alert_params_spec.rb b/spec/lib/gitlab/alert_management/alert_params_spec.rb
index 1fe27365c83..c3171be5e29 100644
--- a/spec/lib/gitlab/alert_management/alert_params_spec.rb
+++ b/spec/lib/gitlab/alert_management/alert_params_spec.rb
@@ -34,7 +34,9 @@ RSpec.describe Gitlab::AlertManagement::AlertParams do
hosts: ['gitlab.com'],
payload: payload,
started_at: started_at,
- fingerprint: nil
+ ended_at: nil,
+ fingerprint: nil,
+ environment: nil
)
end
diff --git a/spec/lib/gitlab/alert_management/payload/base_spec.rb b/spec/lib/gitlab/alert_management/payload/base_spec.rb
new file mode 100644
index 00000000000..e0f63bad05d
--- /dev/null
+++ b/spec/lib/gitlab/alert_management/payload/base_spec.rb
@@ -0,0 +1,210 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::AlertManagement::Payload::Base do
+ let_it_be(:project) { create(:project) }
+ let(:raw_payload) { {} }
+ let(:payload_class) { described_class }
+
+ subject(:parsed_payload) { payload_class.new(project: project, payload: raw_payload) }
+
+ describe '.attribute' do
+ subject { parsed_payload.test }
+
+ context 'with a single path provided' do
+ let(:payload_class) do
+ Class.new(described_class) do
+ attribute :test, paths: [['test']]
+ end
+ end
+
+ it { is_expected.to be_nil }
+
+ context 'and a matching value' do
+ let(:raw_payload) { { 'test' => 'value' } }
+
+ it { is_expected.to eq 'value' }
+ end
+ end
+
+ context 'with multiple paths provided' do
+ let(:payload_class) do
+ Class.new(described_class) do
+ attribute :test, paths: [['test'], %w(alt test)]
+ end
+ end
+
+ it { is_expected.to be_nil }
+
+ context 'and a matching value' do
+ let(:raw_payload) { { 'alt' => { 'test' => 'value' } } }
+
+ it { is_expected.to eq 'value' }
+ end
+ end
+
+ context 'with a fallback provided' do
+ let(:payload_class) do
+ Class.new(described_class) do
+ attribute :test, paths: [['test']], fallback: -> { 'fallback' }
+ end
+ end
+
+ it { is_expected.to eq('fallback') }
+
+ context 'and a matching value' do
+ let(:raw_payload) { { 'test' => 'value' } }
+
+ it { is_expected.to eq 'value' }
+ end
+ end
+
+ context 'with a time type provided' do
+ let(:test_time) { Time.current.change(usec: 0) }
+
+ let(:payload_class) do
+ Class.new(described_class) do
+ attribute :test, paths: [['test']], type: :time
+ end
+ end
+
+ it { is_expected.to be_nil }
+
+ context 'with a compatible matching value' do
+ let(:raw_payload) { { 'test' => test_time.to_s } }
+
+ it { is_expected.to eq test_time }
+ end
+
+ context 'with a value in rfc3339 format' do
+ let(:raw_payload) { { 'test' => test_time.rfc3339 } }
+
+ it { is_expected.to eq test_time }
+ end
+
+ context 'with an incompatible matching value' do
+ let(:raw_payload) { { 'test' => 'bad time' } }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context 'with an integer type provided' do
+ let(:payload_class) do
+ Class.new(described_class) do
+ attribute :test, paths: [['test']], type: :integer
+ end
+ end
+
+ it { is_expected.to be_nil }
+
+ context 'with a compatible matching value' do
+ let(:raw_payload) { { 'test' => '15' } }
+
+ it { is_expected.to eq 15 }
+ end
+
+ context 'with an incompatible matching value' do
+ let(:raw_payload) { { 'test' => String } }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with an incompatible matching value' do
+ let(:raw_payload) { { 'test' => 'apple' } }
+
+ it { is_expected.to be_nil }
+ end
+ end
+ end
+
+ describe '#alert_params' do
+ before do
+ allow(parsed_payload).to receive(:title).and_return('title')
+ allow(parsed_payload).to receive(:description).and_return('description')
+ end
+
+ subject { parsed_payload.alert_params }
+
+ it { is_expected.to eq({ description: 'description', project_id: project.id, title: 'title' }) }
+ end
+
+ describe '#gitlab_fingerprint' do
+ subject { parsed_payload.gitlab_fingerprint }
+
+ it { is_expected.to be_nil }
+
+ context 'when plain_gitlab_fingerprint is defined' do
+ before do
+ allow(parsed_payload)
+ .to receive(:plain_gitlab_fingerprint)
+ .and_return('fingerprint')
+ end
+
+ it 'returns a fingerprint' do
+ is_expected.to eq(Digest::SHA1.hexdigest('fingerprint'))
+ end
+ end
+ end
+
+ describe '#environment' do
+ let_it_be(:environment) { create(:environment, project: project, name: 'production') }
+
+ subject { parsed_payload.environment }
+
+ before do
+ allow(parsed_payload).to receive(:environment_name).and_return(environment_name)
+ end
+
+ context 'without an environment name' do
+ let(:environment_name) { nil }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with a non-matching environment name' do
+ let(:environment_name) { 'other_environment' }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with a matching environment name' do
+ let(:environment_name) { 'production' }
+
+ it { is_expected.to eq(environment) }
+ end
+ end
+
+ describe '#resolved?' do
+ before do
+ allow(parsed_payload).to receive(:status).and_return(status)
+ end
+
+ subject { parsed_payload.resolved? }
+
+ context 'when status is not defined' do
+ let(:status) { nil }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when status is not resovled' do
+ let(:status) { 'firing' }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when status is resovled' do
+ let(:status) { 'resolved' }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '#has_required_attributes?' do
+ subject { parsed_payload.has_required_attributes? }
+
+ it { is_expected.to be(true) }
+ end
+end
diff --git a/spec/lib/gitlab/alert_management/payload/generic_spec.rb b/spec/lib/gitlab/alert_management/payload/generic_spec.rb
new file mode 100644
index 00000000000..538a822503e
--- /dev/null
+++ b/spec/lib/gitlab/alert_management/payload/generic_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::AlertManagement::Payload::Generic do
+ let_it_be(:project) { build_stubbed(:project) }
+ let(:raw_payload) { {} }
+
+ let(:parsed_payload) { described_class.new(project: project, payload: raw_payload) }
+
+ it_behaves_like 'subclass has expected api'
+
+ describe '#title' do
+ subject { parsed_payload.title }
+
+ it_behaves_like 'parsable alert payload field with fallback', 'New: Incident', 'title'
+ end
+
+ describe '#severity' do
+ subject { parsed_payload.severity }
+
+ it_behaves_like 'parsable alert payload field with fallback', 'critical', 'severity'
+ end
+
+ describe '#monitoring_tool' do
+ subject { parsed_payload.monitoring_tool }
+
+ it_behaves_like 'parsable alert payload field', 'monitoring_tool'
+ end
+
+ describe '#service' do
+ subject { parsed_payload.service }
+
+ it_behaves_like 'parsable alert payload field', 'service'
+ end
+
+ describe '#hosts' do
+ subject { parsed_payload.hosts }
+
+ it_behaves_like 'parsable alert payload field', 'hosts'
+ end
+
+ describe '#starts_at' do
+ let(:current_time) { Time.current.change(usec: 0).utc }
+
+ subject { parsed_payload.starts_at }
+
+ around do |example|
+ Timecop.freeze(current_time) { example.run }
+ end
+
+ context 'without start_time' do
+ it { is_expected.to eq(current_time) }
+ end
+
+ context "with start_time" do
+ let(:value) { 10.minutes.ago.change(usec: 0).utc }
+
+ before do
+ raw_payload['start_time'] = value.to_s
+ end
+
+ it { is_expected.to eq(value) }
+ end
+ end
+
+ describe '#runbook' do
+ subject { parsed_payload.runbook }
+
+ it_behaves_like 'parsable alert payload field', 'runbook'
+ end
+
+ describe '#gitlab_fingerprint' do
+ let(:plain_fingerprint) { 'fingerprint' }
+ let(:raw_payload) { { 'fingerprint' => plain_fingerprint } }
+
+ subject { parsed_payload.gitlab_fingerprint }
+
+ it 'returns a fingerprint' do
+ is_expected.to eq(Digest::SHA1.hexdigest(plain_fingerprint))
+ end
+ end
+
+ describe '#environment_name' do
+ subject { parsed_payload.environment_name }
+
+ it_behaves_like 'parsable alert payload field', 'gitlab_environment_name'
+ end
+end
diff --git a/spec/lib/gitlab/alert_management/payload/managed_prometheus_spec.rb b/spec/lib/gitlab/alert_management/payload/managed_prometheus_spec.rb
new file mode 100644
index 00000000000..862b5b2bdc3
--- /dev/null
+++ b/spec/lib/gitlab/alert_management/payload/managed_prometheus_spec.rb
@@ -0,0 +1,167 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::AlertManagement::Payload::ManagedPrometheus do
+ let_it_be(:project) { create(:project) }
+ let(:raw_payload) { {} }
+
+ let(:parsed_payload) { described_class.new(project: project, payload: raw_payload) }
+
+ it_behaves_like 'subclass has expected api'
+
+ shared_context 'with gitlab alert' do
+ let_it_be(:gitlab_alert) { create(:prometheus_alert, project: project) }
+ let(:metric_id) { gitlab_alert.prometheus_metric_id.to_s }
+ let(:alert_id) { gitlab_alert.id.to_s }
+ end
+
+ describe '#metric_id' do
+ subject { parsed_payload.metric_id }
+
+ it { is_expected.to be_nil }
+
+ context 'with gitlab_alert_id' do
+ let(:raw_payload) { { 'labels' => { 'gitlab_alert_id' => '12' } } }
+
+ it { is_expected.to eq(12) }
+ end
+ end
+
+ describe '#gitlab_prometheus_alert_id' do
+ subject { parsed_payload.gitlab_prometheus_alert_id }
+
+ it { is_expected.to be_nil }
+
+ context 'with gitlab_alert_id' do
+ let(:raw_payload) { { 'labels' => { 'gitlab_prometheus_alert_id' => '12' } } }
+
+ it { is_expected.to eq(12) }
+ end
+ end
+
+ describe '#gitlab_alert' do
+ subject { parsed_payload.gitlab_alert }
+
+ context 'without alert info in payload' do
+ it { is_expected.to be_nil }
+ end
+
+ context 'with metric id in payload' do
+ let(:raw_payload) { { 'labels' => { 'gitlab_alert_id' => metric_id } } }
+ let(:metric_id) { '-1' }
+
+ context 'without matching alert' do
+ it { is_expected.to be_nil }
+ end
+
+ context 'with matching alert' do
+ include_context 'with gitlab alert'
+
+ it { is_expected.to eq(gitlab_alert) }
+
+ context 'when unclear which alert applies' do
+ # With multiple alerts for different environments,
+ # we can't be sure which prometheus alert the payload
+ # belongs to
+ let_it_be(:another_alert) do
+ create(:prometheus_alert,
+ prometheus_metric: gitlab_alert.prometheus_metric,
+ project: project)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+ end
+
+ context 'with alert id' do
+ # gitlab_prometheus_alert_id is a stronger identifier,
+ # but was added after gitlab_alert_id; we won't
+ # see it without gitlab_alert_id also present
+ let(:raw_payload) do
+ {
+ 'labels' => {
+ 'gitlab_alert_id' => metric_id,
+ 'gitlab_prometheus_alert_id' => alert_id
+ }
+ }
+ end
+
+ context 'without matching alert' do
+ let(:alert_id) { '-1' }
+ let(:metric_id) { '-1' }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with matching alerts' do
+ include_context 'with gitlab alert'
+
+ it { is_expected.to eq(gitlab_alert) }
+ end
+ end
+ end
+
+ describe '#full_query' do
+ subject { parsed_payload.full_query }
+
+ it { is_expected.to be_nil }
+
+ context 'with gitlab alert' do
+ include_context 'with gitlab alert'
+
+ let(:raw_payload) { { 'labels' => { 'gitlab_alert_id' => metric_id } } }
+
+ it { is_expected.to eq(gitlab_alert.full_query) }
+ end
+
+ context 'with sufficient fallback info' do
+ let(:raw_payload) { { 'generatorURL' => 'http://localhost:9090/graph?g0.expr=vector%281%29' } }
+
+ it { is_expected.to eq('vector(1)') }
+ end
+ end
+
+ describe '#environment' do
+ subject { parsed_payload.environment }
+
+ context 'with gitlab alert' do
+ include_context 'with gitlab alert'
+
+ let(:raw_payload) { { 'labels' => { 'gitlab_alert_id' => metric_id } } }
+
+ it { is_expected.to eq(gitlab_alert.environment) }
+ end
+
+ context 'with sufficient fallback info' do
+ let_it_be(:environment) { create(:environment, project: project, name: 'production') }
+ let(:raw_payload) do
+ {
+ 'labels' => {
+ 'gitlab_alert_id' => '-1',
+ 'gitlab_environment_name' => 'production'
+ }
+ }
+ end
+
+ it { is_expected.to eq(environment) }
+ end
+ end
+
+ describe '#metrics_dashboard_url' do
+ subject { parsed_payload.metrics_dashboard_url }
+
+ context 'without alert' do
+ it { is_expected.to be_nil }
+ end
+
+ context 'with gitlab alert' do
+ include_context 'gitlab-managed prometheus alert attributes' do
+ let(:raw_payload) { payload }
+ end
+
+ it { is_expected.to eq(dashboard_url_for_alert) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/alert_management/payload/prometheus_spec.rb b/spec/lib/gitlab/alert_management/payload/prometheus_spec.rb
new file mode 100644
index 00000000000..457db58a28b
--- /dev/null
+++ b/spec/lib/gitlab/alert_management/payload/prometheus_spec.rb
@@ -0,0 +1,240 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::AlertManagement::Payload::Prometheus do
+ let_it_be(:project) { create(:project) }
+ let(:raw_payload) { {} }
+
+ let(:parsed_payload) { described_class.new(project: project, payload: raw_payload) }
+
+ it_behaves_like 'subclass has expected api'
+
+ shared_context 'with environment' do
+ let_it_be(:environment) { create(:environment, project: project, name: 'production') }
+ end
+
+ describe '#title' do
+ subject { parsed_payload.title }
+
+ it_behaves_like 'parsable alert payload field',
+ 'annotations/title',
+ 'annotations/summary',
+ 'labels/alertname'
+ end
+
+ describe '#description' do
+ subject { parsed_payload.description }
+
+ it_behaves_like 'parsable alert payload field', 'annotations/description'
+ end
+
+ describe '#annotations' do
+ subject { parsed_payload.annotations }
+
+ it_behaves_like 'parsable alert payload field', 'annotations'
+ end
+
+ describe '#status' do
+ subject { parsed_payload.status }
+
+ it_behaves_like 'parsable alert payload field', 'status'
+ end
+
+ describe '#starts_at' do
+ let(:current_time) { Time.current.utc }
+
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ subject { parsed_payload.starts_at }
+
+ context 'without payload' do
+ it { is_expected.to eq(current_time) }
+ end
+
+ context "with startsAt" do
+ let(:value) { 10.minutes.ago.change(usec: 0).utc }
+ let(:raw_payload) { { 'startsAt' => value.rfc3339 } }
+
+ it { is_expected.to eq(value) }
+ end
+ end
+
+ describe '#ends_at' do
+ subject { parsed_payload.ends_at }
+
+ context 'without payload' do
+ it { is_expected.to be_nil }
+ end
+
+ context "with endsAt" do
+ let(:value) { Time.current.change(usec: 0).utc }
+ let(:raw_payload) { { 'endsAt' => value.rfc3339 } }
+
+ it { is_expected.to eq(value) }
+ end
+ end
+
+ describe '#generator_url' do
+ subject { parsed_payload.generator_url }
+
+ it_behaves_like 'parsable alert payload field', 'generatorURL'
+ end
+
+ describe '#runbook' do
+ subject { parsed_payload.runbook }
+
+ it_behaves_like 'parsable alert payload field', 'annotations/runbook'
+ end
+
+ describe '#alert_markdown' do
+ subject { parsed_payload.alert_markdown }
+
+ it_behaves_like 'parsable alert payload field', 'annotations/gitlab_incident_markdown'
+ end
+
+ describe '#environment_name' do
+ subject { parsed_payload.environment_name }
+
+ it_behaves_like 'parsable alert payload field', 'labels/gitlab_environment_name'
+ end
+
+ describe '#gitlab_y_label' do
+ subject { parsed_payload.gitlab_y_label }
+
+ it_behaves_like 'parsable alert payload field',
+ 'annotations/gitlab_y_label',
+ 'annotations/title',
+ 'annotations/summary',
+ 'labels/alertname'
+ end
+
+ describe '#monitoring_tool' do
+ subject { parsed_payload.monitoring_tool }
+
+ it { is_expected.to eq('Prometheus') }
+ end
+
+ describe '#full_query' do
+ using RSpec::Parameterized::TableSyntax
+
+ subject { parsed_payload.full_query }
+
+ where(:generator_url, :expected_query) do
+ nil | nil
+ 'http://localhost' | nil
+ 'invalid url' | nil
+ 'http://localhost:9090/graph?g1.expr=vector%281%29' | nil
+ 'http://localhost:9090/graph?g0.expr=vector%281%29' | 'vector(1)'
+ end
+
+ with_them do
+ let(:raw_payload) { { 'generatorURL' => generator_url } }
+
+ it { is_expected.to eq(expected_query) }
+ end
+ end
+
+ describe '#environment' do
+ subject { parsed_payload.environment }
+
+ it { is_expected.to be_nil }
+
+ context 'with environment_name' do
+ let(:raw_payload) { { 'labels' => { 'gitlab_environment_name' => 'production' } } }
+
+ it { is_expected.to be_nil }
+
+ context 'with matching environment' do
+ include_context 'with environment'
+
+ it { is_expected.to eq(environment) }
+ end
+ end
+ end
+
+ describe '#gitlab_fingerprint' do
+ subject { parsed_payload.gitlab_fingerprint }
+
+ let(:raw_payload) do
+ {
+ 'startsAt' => Time.current.to_s,
+ 'generatorURL' => 'http://localhost:9090/graph?g0.expr=vector%281%29',
+ 'annotations' => { 'title' => 'title' }
+ }
+ end
+
+ it 'returns a fingerprint' do
+ plain_fingerprint = [
+ parsed_payload.send(:starts_at_raw),
+ parsed_payload.title,
+ parsed_payload.full_query
+ ].join('/')
+
+ is_expected.to eq(Digest::SHA1.hexdigest(plain_fingerprint))
+ end
+ end
+
+ describe '#metrics_dashboard_url' do
+ include_context 'self-managed prometheus alert attributes' do
+ let(:raw_payload) { payload }
+ end
+
+ subject { parsed_payload.metrics_dashboard_url }
+
+ it { is_expected.to eq(dashboard_url_for_alert) }
+
+ context 'without environment' do
+ let(:raw_payload) { payload.except('labels') }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'without full query' do
+ let(:raw_payload) { payload.except('generatorURL') }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'without title' do
+ let(:raw_payload) { payload.except('annotations') }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#has_required_attributes?' do
+ let(:starts_at) { Time.current.change(usec: 0).utc }
+ let(:raw_payload) { { 'annotations' => { 'title' => 'title' }, 'startsAt' => starts_at.rfc3339 } }
+
+ subject { parsed_payload.has_required_attributes? }
+
+ it { is_expected.to be_truthy }
+
+ context 'without project' do
+ let(:parsed_payload) { described_class.new(project: nil, payload: raw_payload) }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'without title' do
+ let(:raw_payload) { { 'startsAt' => starts_at.rfc3339 } }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'without startsAt' do
+ let(:raw_payload) { { 'annotations' => { 'title' => 'title' } } }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'without payload' do
+ let(:parsed_payload) { described_class.new(project: project, payload: nil) }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/alert_management/payload_spec.rb b/spec/lib/gitlab/alert_management/payload_spec.rb
new file mode 100644
index 00000000000..44b55e228c5
--- /dev/null
+++ b/spec/lib/gitlab/alert_management/payload_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::AlertManagement::Payload do
+ describe '#parse' do
+ let_it_be(:project) { build_stubbed(:project) }
+ let(:payload) { {} }
+
+ context 'without a monitoring_tool specified by caller' do
+ subject { described_class.parse(project, payload) }
+
+ context 'without a monitoring tool in the payload' do
+ it { is_expected.to be_a Gitlab::AlertManagement::Payload::Generic }
+ end
+
+ context 'with the payload specifying Prometheus' do
+ let(:payload) { { 'monitoring_tool' => 'Prometheus' } }
+
+ it { is_expected.to be_a Gitlab::AlertManagement::Payload::Prometheus }
+
+ context 'with gitlab-managed attributes' do
+ let(:payload) { { 'monitoring_tool' => 'Prometheus', 'labels' => { 'gitlab_alert_id' => '12' } } }
+
+ it { is_expected.to be_a Gitlab::AlertManagement::Payload::ManagedPrometheus }
+ end
+ end
+
+ context 'with the payload specifying an unknown tool' do
+ let(:payload) { { 'monitoring_tool' => 'Custom Tool' } }
+
+ it { is_expected.to be_a Gitlab::AlertManagement::Payload::Generic }
+ end
+ end
+
+ context 'with monitoring_tool specified by caller' do
+ subject { described_class.parse(project, payload, monitoring_tool: monitoring_tool) }
+
+ context 'as Prometheus' do
+ let(:monitoring_tool) { 'Prometheus' }
+
+ context 'with an externally managed prometheus payload' do
+ it { is_expected.to be_a Gitlab::AlertManagement::Payload::Prometheus }
+ end
+
+ context 'with a self-managed prometheus payload' do
+ let(:payload) { { 'labels' => { 'gitlab_alert_id' => '14' } } }
+
+ it { is_expected.to be_a Gitlab::AlertManagement::Payload::ManagedPrometheus }
+ end
+ end
+
+ context 'as an unknown tool' do
+ let(:monitoring_tool) { 'Custom Tool' }
+
+ it { is_expected.to be_a Gitlab::AlertManagement::Payload::Generic }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb b/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb
index 0489108b159..ff5ab1116fa 100644
--- a/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb
+++ b/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb
@@ -7,10 +7,12 @@ RSpec.describe Gitlab::Alerting::NotificationPayloadParser do
describe '.call' do
let(:starts_at) { Time.current.change(usec: 0) }
+ let(:ends_at) { Time.current.change(usec: 0) }
let(:payload) do
{
'title' => 'alert title',
'start_time' => starts_at.rfc3339,
+ 'end_time' => ends_at.rfc3339,
'description' => 'Description',
'monitoring_tool' => 'Monitoring tool name',
'service' => 'Service',
@@ -32,7 +34,8 @@ RSpec.describe Gitlab::Alerting::NotificationPayloadParser do
'hosts' => ['gitlab.com'],
'severity' => 'low'
},
- 'startsAt' => starts_at.rfc3339
+ 'startsAt' => starts_at.rfc3339,
+ 'endsAt' => ends_at.rfc3339
}
)
end
@@ -124,11 +127,24 @@ RSpec.describe Gitlab::Alerting::NotificationPayloadParser do
end
end
+ context 'with environment' do
+ let(:environment) { create(:environment, project: project) }
+
+ before do
+ payload[:gitlab_environment_name] = environment.name
+ end
+
+ it 'sets the environment ' do
+ expect(subject.dig('annotations', 'environment')).to eq(environment)
+ end
+ end
+
context 'when payload attributes have blank lines' do
let(:payload) do
{
'title' => '',
'start_time' => '',
+ 'end_time' => '',
'description' => '',
'monitoring_tool' => '',
'service' => '',
diff --git a/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb b/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb
new file mode 100644
index 00000000000..d232e509e00
--- /dev/null
+++ b/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder do
+ context 'when no measurement identifiers are given' do
+ it 'returns empty array' do
+ expect(described_class.new(measurement_identifiers: []).execute).to be_empty
+ end
+ end
+
+ context 'when measurement identifiers are given' do
+ let_it_be(:user_1) { create(:user) }
+ let_it_be(:project_1) { create(:project, namespace: user_1.namespace, creator: user_1) }
+ let_it_be(:project_2) { create(:project, namespace: user_1.namespace, creator: user_1) }
+ let_it_be(:project_3) { create(:project, namespace: user_1.namespace, creator: user_1) }
+
+ let(:recorded_at) { 2.days.ago }
+ let(:projects_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:projects) }
+ let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:users) }
+ let(:measurement_identifiers) { [projects_measurement_identifier, users_measurement_identifier] }
+
+ subject { described_class.new(measurement_identifiers: measurement_identifiers, recorded_at: recorded_at).execute }
+
+ it 'returns worker arguments' do
+ expect(subject).to eq([
+ [projects_measurement_identifier, project_1.id, project_3.id, recorded_at],
+ [users_measurement_identifier, user_1.id, user_1.id, recorded_at]
+ ])
+ end
+
+ context 'when bogus measurement identifiers are given' do
+ before do
+ measurement_identifiers << 'bogus1'
+ measurement_identifiers << 'bogus2'
+ end
+
+ it 'skips bogus measurement identifiers' do
+ expect(subject).to eq([
+ [projects_measurement_identifier, project_1.id, project_3.id, recorded_at],
+ [users_measurement_identifier, user_1.id, user_1.id, recorded_at]
+ ])
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/anonymous_session_spec.rb b/spec/lib/gitlab/anonymous_session_spec.rb
index 0f0795cd9fc..671d452ad13 100644
--- a/spec/lib/gitlab/anonymous_session_spec.rb
+++ b/spec/lib/gitlab/anonymous_session_spec.rb
@@ -8,45 +8,36 @@ RSpec.describe Gitlab::AnonymousSession, :clean_gitlab_redis_shared_state do
subject { new_anonymous_session }
- def new_anonymous_session(session_id = default_session_id)
- described_class.new('127.0.0.1', session_id: session_id)
+ def new_anonymous_session
+ described_class.new('127.0.0.1')
end
- describe '#store_session_id_per_ip' do
+ describe '#store_session_ip' do
it 'adds session id to proper key' do
- subject.store_session_id_per_ip
+ subject.count_session_ip
Gitlab::Redis::SharedState.with do |redis|
- expect(redis.smembers("session:lookup:ip:gitlab:127.0.0.1")).to eq [default_session_id]
+ expect(redis.get("session:lookup:ip:gitlab2:127.0.0.1").to_i).to eq 1
end
end
it 'adds expiration time to key' do
Timecop.freeze do
- subject.store_session_id_per_ip
+ subject.count_session_ip
Gitlab::Redis::SharedState.with do |redis|
- expect(redis.ttl("session:lookup:ip:gitlab:127.0.0.1")).to eq(24.hours.to_i)
+ expect(redis.ttl("session:lookup:ip:gitlab2:127.0.0.1")).to eq(24.hours.to_i)
end
end
end
- it 'adds id only once' do
- subject.store_session_id_per_ip
- subject.store_session_id_per_ip
-
- Gitlab::Redis::SharedState.with do |redis|
- expect(redis.smembers("session:lookup:ip:gitlab:127.0.0.1")).to eq [default_session_id]
- end
- end
-
context 'when there is already one session' do
- it 'adds session id to proper key' do
- subject.store_session_id_per_ip
- new_anonymous_session(additional_session_id).store_session_id_per_ip
+ it 'increments the session count' do
+ subject.count_session_ip
+ new_anonymous_session.count_session_ip
Gitlab::Redis::SharedState.with do |redis|
- expect(redis.smembers("session:lookup:ip:gitlab:127.0.0.1")).to contain_exactly(default_session_id, additional_session_id)
+ expect(redis.get("session:lookup:ip:gitlab2:127.0.0.1").to_i).to eq(2)
end
end
end
@@ -55,24 +46,22 @@ RSpec.describe Gitlab::AnonymousSession, :clean_gitlab_redis_shared_state do
describe '#stored_sessions' do
it 'returns all anonymous sessions per ip' do
Gitlab::Redis::SharedState.with do |redis|
- redis.sadd("session:lookup:ip:gitlab:127.0.0.1", default_session_id)
- redis.sadd("session:lookup:ip:gitlab:127.0.0.1", additional_session_id)
+ redis.set("session:lookup:ip:gitlab2:127.0.0.1", 2)
end
- expect(subject.stored_sessions).to eq(2)
+ expect(subject.session_count).to eq(2)
end
end
it 'removes obsolete lookup through ip entries' do
Gitlab::Redis::SharedState.with do |redis|
- redis.sadd("session:lookup:ip:gitlab:127.0.0.1", default_session_id)
- redis.sadd("session:lookup:ip:gitlab:127.0.0.1", additional_session_id)
+ redis.set("session:lookup:ip:gitlab2:127.0.0.1", 2)
end
- subject.cleanup_session_per_ip_entries
+ subject.cleanup_session_per_ip_count
Gitlab::Redis::SharedState.with do |redis|
- expect(redis.smembers("session:lookup:ip:gitlab:127.0.0.1")).to eq [additional_session_id]
+ expect(redis.exists("session:lookup:ip:gitlab2:127.0.0.1")).to eq(false)
end
end
end
diff --git a/spec/lib/gitlab/app_text_logger_spec.rb b/spec/lib/gitlab/app_text_logger_spec.rb
index 04c2e946640..e8bee0f9903 100644
--- a/spec/lib/gitlab/app_text_logger_spec.rb
+++ b/spec/lib/gitlab/app_text_logger_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Gitlab::AppTextLogger do
end
it 'logs time in UTC with ISO8601.3 standard' do
- Timecop.freeze do
+ freeze_time do
expect(subject.format_message('INFO', Time.now, nil, string_message))
.to include(Time.now.utc.iso8601(3))
end
diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb
index 1b669e691e7..6b93634690c 100644
--- a/spec/lib/gitlab/asciidoc_spec.rb
+++ b/spec/lib/gitlab/asciidoc_spec.rb
@@ -404,7 +404,7 @@ module Gitlab
++++
stem:[2+2] is 4
- MD
+ MD
expect(render(input, context)).to include('<pre data-math-style="display" class="code math js-render-math"><code>eta_x gamma</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>')
diff --git a/spec/lib/gitlab/auth/atlassian/auth_hash_spec.rb b/spec/lib/gitlab/auth/atlassian/auth_hash_spec.rb
new file mode 100644
index 00000000000..c57b15361c4
--- /dev/null
+++ b/spec/lib/gitlab/auth/atlassian/auth_hash_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Auth::Atlassian::AuthHash do
+ let(:auth_hash) do
+ described_class.new(
+ OmniAuth::AuthHash.new(uid: 'john', credentials: credentials)
+ )
+ end
+
+ let(:credentials) do
+ {
+ token: 'super_secret_token',
+ refresh_token: 'super_secret_refresh_token',
+ expires_at: 2.weeks.from_now.to_i,
+ expires: true
+ }
+ end
+
+ describe '#uid' do
+ it 'returns the correct uid' do
+ expect(auth_hash.uid).to eq('john')
+ end
+ end
+
+ describe '#token' do
+ it 'returns the correct token' do
+ expect(auth_hash.token).to eq(credentials[:token])
+ end
+ end
+
+ describe '#refresh_token' do
+ it 'returns the correct refresh token' do
+ expect(auth_hash.refresh_token).to eq(credentials[:refresh_token])
+ end
+ end
+
+ describe '#token' do
+ it 'returns the correct expires boolean' do
+ expect(auth_hash.expires?).to eq(credentials[:expires])
+ end
+ end
+
+ describe '#token' do
+ it 'returns the correct expiration' do
+ expect(auth_hash.expires_at).to eq(credentials[:expires_at])
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/atlassian/identity_linker_spec.rb b/spec/lib/gitlab/auth/atlassian/identity_linker_spec.rb
new file mode 100644
index 00000000000..ca6b91ac6f1
--- /dev/null
+++ b/spec/lib/gitlab/auth/atlassian/identity_linker_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Auth::Atlassian::IdentityLinker do
+ let(:user) { create(:user) }
+ let(:extern_uid) { generate(:username) }
+ let(:oauth) do
+ OmniAuth::AuthHash.new(
+ uid: extern_uid,
+ provider: 'atlassian_oauth2',
+ info: { name: 'John', email: 'john@mail.com' },
+ credentials: credentials
+ )
+ end
+
+ let(:credentials) do
+ {
+ token: SecureRandom.alphanumeric(1254),
+ refresh_token: SecureRandom.alphanumeric(45),
+ expires_at: 2.weeks.from_now.to_i,
+ expires: true
+ }
+ end
+
+ subject { described_class.new(user, oauth) }
+
+ context 'linked identity exists' do
+ let!(:identity) { create(:atlassian_identity, user: user, extern_uid: extern_uid) }
+
+ before do
+ subject.link
+ end
+
+ it 'sets #changed? to false' do
+ expect(subject).not_to be_changed
+ end
+
+ it 'does not mark as failed' do
+ expect(subject).not_to be_failed
+ end
+ end
+
+ context 'identity already linked to different user' do
+ let!(:identity) { create(:atlassian_identity, extern_uid: extern_uid) }
+
+ it 'sets #changed? to false' do
+ subject.link
+
+ expect(subject).not_to be_changed
+ end
+
+ it 'exposes error message' do
+ expect(subject.error_message).to eq 'Extern uid has already been taken'
+ end
+ end
+
+ context 'identity needs to be created' do
+ let(:identity) { user.atlassian_identity }
+
+ before do
+ subject.link
+ end
+
+ it_behaves_like 'an atlassian identity'
+
+ it 'sets #changed? to true' do
+ expect(subject).to be_changed
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/atlassian/user_spec.rb b/spec/lib/gitlab/auth/atlassian/user_spec.rb
new file mode 100644
index 00000000000..1db01102bc2
--- /dev/null
+++ b/spec/lib/gitlab/auth/atlassian/user_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Auth::Atlassian::User do
+ let(:oauth_user) { described_class.new(oauth) }
+ let(:gl_user) { oauth_user.gl_user }
+ let(:extern_uid) { generate(:username) }
+ let(:oauth) do
+ OmniAuth::AuthHash.new(
+ uid: extern_uid,
+ provider: 'atlassian_oauth2',
+ info: { name: 'John', email: 'john@mail.com' },
+ credentials: credentials)
+ end
+
+ let(:credentials) do
+ {
+ token: SecureRandom.alphanumeric(1254),
+ refresh_token: SecureRandom.alphanumeric(45),
+ expires_at: 2.weeks.from_now.to_i,
+ expires: true
+ }
+ end
+
+ describe '.assign_identity_from_auth_hash!' do
+ let(:auth_hash) { ::Gitlab::Auth::Atlassian::AuthHash.new(oauth) }
+ let(:identity) { described_class.assign_identity_from_auth_hash!(Atlassian::Identity.new, auth_hash) }
+
+ it_behaves_like 'an atlassian identity'
+ end
+
+ describe '#save' do
+ context 'for an existing user' do
+ context 'with an existing Atlassian Identity' do
+ let!(:existing_user) { create(:atlassian_user, extern_uid: extern_uid) }
+ let(:identity) { gl_user.atlassian_identity }
+
+ before do
+ oauth_user.save # rubocop:disable Rails/SaveBang
+ end
+
+ it 'finds the existing user and identity' do
+ expect(gl_user.id).to eq(existing_user.id)
+ expect(identity.id).to eq(existing_user.atlassian_identity.id)
+ end
+
+ it_behaves_like 'an atlassian identity'
+ end
+
+ context 'for a new user' do
+ it 'creates the user and identity' do
+ oauth_user.save # rubocop:disable Rails/SaveBang
+
+ expect(gl_user).to be_valid
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/ldap/adapter_spec.rb b/spec/lib/gitlab/auth/ldap/adapter_spec.rb
index 78970378b7f..8546d63cf77 100644
--- a/spec/lib/gitlab/auth/ldap/adapter_spec.rb
+++ b/spec/lib/gitlab/auth/ldap/adapter_spec.rb
@@ -128,7 +128,7 @@ RSpec.describe Gitlab::Auth::Ldap::Adapter do
before do
allow(adapter).to receive(:renew_connection_adapter).and_return(ldap)
allow(ldap).to receive(:search) { raise Net::LDAP::Error, "some error" }
- allow(Rails.logger).to receive(:warn)
+ allow(Gitlab::AppLogger).to receive(:warn)
end
context 'retries the operation' do
@@ -152,7 +152,7 @@ RSpec.describe Gitlab::Auth::Ldap::Adapter do
it 'logs the error' do
expect { subject }.to raise_error(Gitlab::Auth::Ldap::LdapConnectionError)
- expect(Rails.logger).to have_received(:warn).with(
+ expect(Gitlab::AppLogger).to have_received(:warn).with(
"LDAP search raised exception Net::LDAP::Error: some error")
end
end
diff --git a/spec/lib/gitlab/auth/ldap/config_spec.rb b/spec/lib/gitlab/auth/ldap/config_spec.rb
index 4287596af8f..e4c87a54365 100644
--- a/spec/lib/gitlab/auth/ldap/config_spec.rb
+++ b/spec/lib/gitlab/auth/ldap/config_spec.rb
@@ -168,7 +168,7 @@ AtlErSqafbECNDSwS5BX8yDpu5yRBJ4xegO/rNlmb8ICRYkuJapD1xXicFOsmfUK
end
it 'logs an error when an invalid key or cert are configured' do
- allow(Rails.logger).to receive(:error)
+ allow(Gitlab::AppLogger).to receive(:error)
stub_ldap_config(
options: {
'host' => 'ldap.example.com',
@@ -183,7 +183,7 @@ AtlErSqafbECNDSwS5BX8yDpu5yRBJ4xegO/rNlmb8ICRYkuJapD1xXicFOsmfUK
config.adapter_options
- expect(Rails.logger).to have_received(:error).with(/LDAP TLS Options/).twice
+ expect(Gitlab::AppLogger).to have_received(:error).with(/LDAP TLS Options/).twice
end
context 'when verify_certificates is enabled' do
diff --git a/spec/lib/gitlab/auth/o_auth/provider_spec.rb b/spec/lib/gitlab/auth/o_auth/provider_spec.rb
index 658a9976cc2..57f17365190 100644
--- a/spec/lib/gitlab/auth/o_auth/provider_spec.rb
+++ b/spec/lib/gitlab/auth/o_auth/provider_spec.rb
@@ -45,7 +45,7 @@ RSpec.describe Gitlab::Auth::OAuth::Provider do
end
end
- describe '#config_for' do
+ describe '.config_for' do
context 'for an LDAP provider' do
context 'when the provider exists' do
it 'returns the config' do
@@ -91,4 +91,46 @@ RSpec.describe Gitlab::Auth::OAuth::Provider do
end
end
end
+
+ describe '.label_for' do
+ subject { described_class.label_for(name) }
+
+ context 'when configuration specifies a custom label' do
+ let(:name) { 'google_oauth2' }
+ let(:label) { 'Custom Google Provider' }
+ let(:provider) { OpenStruct.new({ 'name' => name, 'label' => label }) }
+
+ before do
+ stub_omniauth_setting(providers: [provider])
+ end
+
+ it 'returns the custom label name' do
+ expect(subject).to eq(label)
+ end
+ end
+
+ context 'when configuration does not specify a custom label' do
+ let(:provider) { OpenStruct.new({ 'name' => name } ) }
+
+ before do
+ stub_omniauth_setting(providers: [provider])
+ end
+
+ context 'when the name does not correspond to a label mapping' do
+ let(:name) { 'twitter' }
+
+ it 'returns the titleized name' do
+ expect(subject).to eq(name.titleize)
+ end
+ end
+ end
+
+ context 'when the name corresponds to a label mapping' do
+ let(:name) { 'gitlab' }
+
+ it 'returns the mapped name' do
+ expect(subject).to eq('GitLab.com')
+ end
+ end
+ 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 12e774ec1f8..243d0a4cb45 100644
--- a/spec/lib/gitlab/auth/o_auth/user_spec.rb
+++ b/spec/lib/gitlab/auth/o_auth/user_spec.rb
@@ -202,7 +202,56 @@ RSpec.describe Gitlab::Auth::OAuth::User do
include_examples "to verify compliance with allow_single_sign_on"
end
- context "with auto_link_user enabled" do
+ context "with auto_link_user enabled for a different provider" do
+ before do
+ stub_omniauth_config(auto_link_user: ['saml'])
+ end
+
+ context "and a current GitLab user with a matching email" do
+ let!(:existing_user) { create(:user, email: 'john@mail.com', username: 'john') }
+
+ it "adds the OmniAuth identity to the GitLab user account" do
+ oauth_user.save
+
+ expect(gl_user).not_to be_valid
+ end
+ end
+
+ context "and no current GitLab user with a matching email" do
+ include_examples "to verify compliance with allow_single_sign_on"
+ end
+ end
+
+ context "with auto_link_user enabled for the correct provider" do
+ before do
+ stub_omniauth_config(auto_link_user: ['twitter'])
+ end
+
+ context "and a current GitLab user with a matching email" do
+ let!(:existing_user) { create(:user, email: 'john@mail.com', username: 'john') }
+
+ it "adds the OmniAuth identity to the GitLab user account" do
+ oauth_user.save
+
+ expect(gl_user).to be_valid
+ expect(gl_user.username).to eql 'john'
+ expect(gl_user.email).to eql 'john@mail.com'
+ expect(gl_user.identities.length).to be 1
+ identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
+ expect(identities_as_hash).to match_array(
+ [
+ { provider: 'twitter', extern_uid: uid }
+ ]
+ )
+ end
+ end
+
+ context "and no current GitLab user with a matching email" do
+ include_examples "to verify compliance with allow_single_sign_on"
+ end
+ end
+
+ context "with auto_link_user enabled for all providers" do
before do
stub_omniauth_config(auto_link_user: true)
end
@@ -421,7 +470,7 @@ RSpec.describe Gitlab::Auth::OAuth::User do
context "with both auto_link_user and auto_link_ldap_user enabled" do
before do
- stub_omniauth_config(auto_link_user: true, auto_link_ldap_user: true)
+ stub_omniauth_config(auto_link_user: ['twitter'], auto_link_ldap_user: true)
end
context "and at least one LDAP provider is defined" do
diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb
index b6a8ac31074..74360637897 100644
--- a/spec/lib/gitlab/auth_spec.rb
+++ b/spec/lib/gitlab/auth_spec.rb
@@ -358,6 +358,29 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do
.to eq(Gitlab::Auth::Result.new(nil, nil, nil, nil))
end
end
+
+ context 'when using a project access token' do
+ let_it_be(:project_bot_user) { create(:user, :project_bot) }
+ let_it_be(:project_access_token) { create(:personal_access_token, user: project_bot_user) }
+
+ context 'with valid project access token' do
+ before_all do
+ project.add_maintainer(project_bot_user)
+ end
+
+ it 'succeeds' do
+ expect(gl_auth.find_for_git_client(project_bot_user.username, project_access_token.token, project: project, ip: 'ip'))
+ .to eq(Gitlab::Auth::Result.new(project_bot_user, nil, :personal_access_token, described_class.full_authentication_abilities))
+ end
+ end
+
+ context 'with invalid project access token' do
+ it 'fails' do
+ expect(gl_auth.find_for_git_client(project_bot_user.username, project_access_token.token, project: project, ip: 'ip'))
+ .to eq(Gitlab::Auth::Result.new(nil, nil, nil, nil))
+ end
+ end
+ end
end
context 'while using regular user and password' 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 fad33265030..a23b74bcaca 100644
--- a/spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb
@@ -327,6 +327,6 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migrat
end
def ls_files(snippet)
- raw_repository(snippet).ls_files(nil)
+ raw_repository(snippet).ls_files(snippet.default_branch)
end
end
diff --git a/spec/lib/gitlab/background_migration/migrate_to_hashed_storage_spec.rb b/spec/lib/gitlab/background_migration/migrate_to_hashed_storage_spec.rb
new file mode 100644
index 00000000000..0f7bb06e830
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/migrate_to_hashed_storage_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+# rubocop:disable RSpec/FactoriesInMigrationSpecs
+RSpec.describe Gitlab::BackgroundMigration::MigrateToHashedStorage, :sidekiq, :redis do
+ let(:migrator) { Gitlab::HashedStorage::Migrator.new }
+
+ subject(:background_migration) { described_class.new }
+
+ describe '#perform' do
+ let!(:project) { create(:project, :empty_repo, :legacy_storage) }
+
+ context 'with pending rollback' do
+ it 'aborts rollback operation' do
+ Sidekiq::Testing.disable! do
+ Sidekiq::Client.push(
+ 'queue' => ::HashedStorage::ProjectRollbackWorker.queue,
+ 'class' => ::HashedStorage::ProjectRollbackWorker,
+ 'args' => [project.id]
+ )
+
+ expect { background_migration.perform }.to change { migrator.rollback_pending? }.from(true).to(false)
+ end
+ end
+ end
+
+ it 'enqueues legacy projects to be migrated' do
+ Sidekiq::Testing.fake! do
+ expect { background_migration.perform }.to change { Sidekiq::Queues[::HashedStorage::MigratorWorker.queue].size }.by(1)
+ end
+ end
+
+ context 'when executing all jobs' do
+ it 'migrates legacy projects' do
+ Sidekiq::Testing.inline! do
+ expect { background_migration.perform }.to change { project.reload.legacy_storage? }.from(true).to(false)
+ end
+ end
+ end
+ end
+end
+# rubocop:enable RSpec/FactoriesInMigrationSpecs
diff --git a/spec/lib/gitlab/background_migration/set_merge_request_diff_files_count_spec.rb b/spec/lib/gitlab/background_migration/set_merge_request_diff_files_count_spec.rb
index 6e9f51f510a..f23518625e4 100644
--- a/spec/lib/gitlab/background_migration/set_merge_request_diff_files_count_spec.rb
+++ b/spec/lib/gitlab/background_migration/set_merge_request_diff_files_count_spec.rb
@@ -13,11 +13,11 @@ RSpec.describe Gitlab::BackgroundMigration::SetMergeRequestDiffFilesCount, schem
let(:project) { projects.create!(namespace_id: namespace.id) }
let(:merge_request) { merge_requests.create!(source_branch: 'x', target_branch: 'master', target_project_id: project.id) }
- it 'fills the files_count column' do
- empty_diff = merge_request_diffs.create!(merge_request_id: merge_request.id)
- filled_diff = merge_request_diffs.create!(merge_request_id: merge_request.id)
+ let!(:empty_diff) { merge_request_diffs.create!(merge_request_id: merge_request.id) }
+ let!(:filled_diff) { merge_request_diffs.create!(merge_request_id: merge_request.id) }
- 3.times do |n|
+ let!(:filled_diff_files) do
+ 1.upto(3).map do |n|
merge_request_diff_files.create!(
merge_request_diff_id: filled_diff.id,
relative_order: n,
@@ -31,10 +31,21 @@ RSpec.describe Gitlab::BackgroundMigration::SetMergeRequestDiffFilesCount, schem
new_path: ''
)
end
+ end
+ it 'fills the files_count column' do
described_class.new.perform(empty_diff.id, filled_diff.id)
expect(empty_diff.reload.files_count).to eq(0)
expect(filled_diff.reload.files_count).to eq(3)
end
+
+ it 'uses the sentinel value if the actual count is too high' do
+ stub_const("#{described_class}::FILES_COUNT_SENTINEL", filled_diff_files.size - 1)
+
+ described_class.new.perform(empty_diff.id, filled_diff.id)
+
+ expect(empty_diff.reload.files_count).to eq(0)
+ expect(filled_diff.reload.files_count).to eq(described_class::FILES_COUNT_SENTINEL)
+ end
end
diff --git a/spec/lib/gitlab/badge/coverage/template_spec.rb b/spec/lib/gitlab/badge/coverage/template_spec.rb
index 5a0adfd8e59..ba5c1b2ce6e 100644
--- a/spec/lib/gitlab/badge/coverage/template_spec.rb
+++ b/spec/lib/gitlab/badge/coverage/template_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Gitlab::Badge::Coverage::Template do
context 'when its size is larger than the max allowed value' do
before do
- allow(badge).to receive(:customization).and_return({ key_text: 't' * 129 })
+ allow(badge).to receive(:customization).and_return({ key_text: 't' * 65 })
end
it 'returns default value' do
@@ -76,7 +76,7 @@ RSpec.describe Gitlab::Badge::Coverage::Template do
context 'when it is larger than the max allowed value' do
before do
- allow(badge).to receive(:customization).and_return({ key_width: 129 })
+ allow(badge).to receive(:customization).and_return({ key_width: 513 })
end
it 'returns default value' do
diff --git a/spec/lib/gitlab/badge/pipeline/status_spec.rb b/spec/lib/gitlab/badge/pipeline/status_spec.rb
index fcc0d4030fd..b5dabca0477 100644
--- a/spec/lib/gitlab/badge/pipeline/status_spec.rb
+++ b/spec/lib/gitlab/badge/pipeline/status_spec.rb
@@ -78,6 +78,34 @@ RSpec.describe Gitlab::Badge::Pipeline::Status do
expect(badge.status).to eq 'success'
end
end
+
+ context 'when ignored_skipped is set to true' do
+ let(:new_badge) { described_class.new(project, branch, opts: { ignore_skipped: true }) }
+
+ before do
+ pipeline.skip!
+ end
+
+ describe '#status' do
+ it 'uses latest non-skipped status' do
+ expect(new_badge.status).not_to eq 'skipped'
+ end
+ end
+ end
+
+ context 'when ignored_skipped is set to false' do
+ let(:new_badge) { described_class.new(project, branch, opts: { ignore_skipped: false }) }
+
+ before do
+ pipeline.skip!
+ end
+
+ describe '#status' do
+ it 'uses latest status' do
+ expect(new_badge.status).to eq 'skipped'
+ end
+ end
+ end
end
context 'build does not exist' do
diff --git a/spec/lib/gitlab/badge/pipeline/template_spec.rb b/spec/lib/gitlab/badge/pipeline/template_spec.rb
index 2f0d0782369..c78e95852f3 100644
--- a/spec/lib/gitlab/badge/pipeline/template_spec.rb
+++ b/spec/lib/gitlab/badge/pipeline/template_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Gitlab::Badge::Pipeline::Template do
context 'when its size is larger than the max allowed value' do
before do
- allow(badge).to receive(:customization).and_return({ key_text: 't' * 129 })
+ allow(badge).to receive(:customization).and_return({ key_text: 't' * 65 })
end
it 'returns default value' do
@@ -54,7 +54,7 @@ RSpec.describe Gitlab::Badge::Pipeline::Template do
context 'when it is larger than the max allowed value' do
before do
- allow(badge).to receive(:customization).and_return({ key_width: 129 })
+ allow(badge).to receive(:customization).and_return({ key_width: 513 })
end
it 'returns default value' do
diff --git a/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
index 5eb27c51f9e..80ec5ec1fc7 100644
--- a/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
+++ b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
@@ -6,9 +6,10 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
include ImportSpecHelper
let(:import_url) { 'http://my-bitbucket' }
- let(:user) { 'bitbucket' }
+ let(:bitbucket_user) { 'bitbucket' }
+ let(:project_creator) { create(:user, username: 'project_creator', email: 'project_creator@example.org') }
let(:password) { 'test' }
- let(:project) { create(:project, :repository, import_url: import_url) }
+ let(:project) { create(:project, :repository, import_url: import_url, creator: project_creator) }
let(:now) { Time.now.utc.change(usec: 0) }
let(:project_key) { 'TEST' }
let(:repo_slug) { 'rouge' }
@@ -19,7 +20,7 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
before do
data = project.create_or_update_import_data(
data: { project_key: project_key, repo_slug: repo_slug },
- credentials: { base_uri: import_url, user: user, password: password }
+ credentials: { base_uri: import_url, user: bitbucket_user, password: password }
)
data.save
project.save
@@ -51,12 +52,11 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
end
describe '#import_pull_requests' do
- before do
- allow(subject).to receive(:import_repository)
- allow(subject).to receive(:delete_temp_branches)
- allow(subject).to receive(:restore_branches)
+ let(:pull_request_author) { create(:user, username: 'pull_request_author', email: 'pull_request_author@example.org') }
+ let(:note_author) { create(:user, username: 'note_author', email: 'note_author@example.org') }
- pull_request = instance_double(
+ let(:pull_request) do
+ instance_double(
BitbucketServer::Representation::PullRequest,
iid: 10,
source_branch_sha: sample.commits.last,
@@ -67,65 +67,172 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
description: 'This is a test pull request',
state: 'merged',
author: 'Test Author',
- author_email: project.owner.email,
+ author_email: pull_request_author.email,
+ author_username: pull_request_author.username,
created_at: Time.now,
updated_at: Time.now,
raw: {},
merged?: true)
+ end
- allow(subject.client).to receive(:pull_requests).and_return([pull_request])
-
- @merge_event = instance_double(
+ let(:merge_event) do
+ instance_double(
BitbucketServer::Representation::Activity,
comment?: false,
merge_event?: true,
- committer_email: project.owner.email,
+ committer_email: pull_request_author.email,
merge_timestamp: now,
merge_commit: '12345678'
)
+ end
- @pr_note = instance_double(
+ let(:pr_note) do
+ instance_double(
BitbucketServer::Representation::Comment,
note: 'Hello world',
- author_email: 'unknown@gmail.com',
- author_username: 'The Flash',
+ author_email: note_author.email,
+ author_username: note_author.username,
comments: [],
created_at: now,
updated_at: now,
parent_comment: nil)
+ end
- @pr_comment = instance_double(
+ let(:pr_comment) do
+ instance_double(
BitbucketServer::Representation::Activity,
comment?: true,
inline_comment?: false,
merge_event?: false,
- comment: @pr_note)
+ comment: pr_note)
+ end
+
+ before do
+ allow(subject).to receive(:import_repository)
+ allow(subject).to receive(:delete_temp_branches)
+ allow(subject).to receive(:restore_branches)
+
+ allow(subject.client).to receive(:pull_requests).and_return([pull_request])
end
it 'imports merge event' do
- expect(subject.client).to receive(:activities).and_return([@merge_event])
+ expect(subject.client).to receive(:activities).and_return([merge_event])
expect { subject.execute }.to change { MergeRequest.count }.by(1)
merge_request = MergeRequest.first
- expect(merge_request.metrics.merged_by).to eq(project.owner)
- expect(merge_request.metrics.merged_at).to eq(@merge_event.merge_timestamp)
+ expect(merge_request.metrics.merged_by).to eq(pull_request_author)
+ expect(merge_request.metrics.merged_at).to eq(merge_event.merge_timestamp)
expect(merge_request.merge_commit_sha).to eq('12345678')
expect(merge_request.state_id).to eq(3)
end
- it 'imports comments' do
- expect(subject.client).to receive(:activities).and_return([@pr_comment])
+ describe 'pull request author user mapping' do
+ before do
+ allow(subject.client).to receive(:activities).and_return([merge_event])
+ end
- expect { subject.execute }.to change { MergeRequest.count }.by(1)
+ shared_examples 'imports pull requests' do
+ it 'maps user' do
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
- merge_request = MergeRequest.first
- expect(merge_request.notes.count).to eq(1)
- note = merge_request.notes.first
- expect(note.note).to end_with(@pr_note.note)
- expect(note.author).to eq(project.owner)
- expect(note.created_at).to eq(@pr_note.created_at)
- expect(note.updated_at).to eq(@pr_note.created_at)
+ merge_request = MergeRequest.first
+ expect(merge_request.author).to eq(pull_request_author)
+ end
+ end
+
+ context 'when bitbucket_server_user_mapping_by_username feature flag is disabled' do
+ before do
+ stub_feature_flags(bitbucket_server_user_mapping_by_username: false)
+ end
+
+ include_examples 'imports pull requests'
+ end
+
+ context 'when bitbucket_server_user_mapping_by_username feature flag is enabled' do
+ before do
+ stub_feature_flags(bitbucket_server_user_mapping_by_username: true)
+ end
+
+ include_examples 'imports pull requests' do
+ context 'when username is not present' do
+ before do
+ allow(pull_request).to receive(:author_username).and_return(nil)
+ end
+
+ it 'maps by email' do
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.author).to eq(pull_request_author)
+ end
+ end
+ end
+ end
+
+ context 'when user is not found' do
+ before do
+ allow(pull_request).to receive(:author_username).and_return(nil)
+ allow(pull_request).to receive(:author_email).and_return(nil)
+ end
+
+ it 'maps importer user' do
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.author).to eq(project_creator)
+ end
+ end
+ end
+
+ describe 'comments' do
+ shared_examples 'imports comments' do
+ it 'imports comments' do
+ expect(subject.client).to receive(:activities).and_return([pr_comment])
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.notes.count).to eq(1)
+ note = merge_request.notes.first
+ expect(note.note).to end_with(pr_note.note)
+ expect(note.author).to eq(note_author)
+ expect(note.created_at).to eq(pr_note.created_at)
+ expect(note.updated_at).to eq(pr_note.created_at)
+ end
+ end
+
+ context 'when bitbucket_server_user_mapping_by_username feature flag is disabled' do
+ before do
+ stub_feature_flags(bitbucket_server_user_mapping_by_username: false)
+ end
+
+ include_examples 'imports comments'
+ end
+
+ context 'when bitbucket_server_user_mapping_by_username feature flag is enabled' do
+ before do
+ stub_feature_flags(bitbucket_server_user_mapping_by_username: true)
+ end
+
+ include_examples 'imports comments'
+
+ context 'when username is not present' do
+ before do
+ allow(pr_note).to receive(:author_username).and_return(nil)
+ allow(subject.client).to receive(:activities).and_return([pr_comment])
+ end
+
+ it 'maps by email' do
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.notes.count).to eq(1)
+ note = merge_request.notes.first
+ expect(note.author).to eq(note_author)
+ end
+ end
+ end
end
context 'metrics' do
@@ -135,7 +242,7 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
before do
allow(Gitlab::Metrics).to receive(:counter) { counter }
allow(Gitlab::Metrics).to receive(:histogram) { histogram }
- allow(subject.client).to receive(:activities).and_return([@merge_event])
+ allow(subject.client).to receive(:activities).and_return([merge_event])
end
it 'counts and measures duration of imported projects' do
@@ -170,73 +277,137 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
end
end
- it 'imports threaded discussions' do
- reply = instance_double(
- BitbucketServer::Representation::PullRequestComment,
- author_email: 'someuser@gitlab.com',
- author_username: 'Batman',
- note: 'I agree',
- created_at: now,
- updated_at: now)
+ describe 'threaded discussions' do
+ let(:reply_author) { create(:user, username: 'reply_author', email: 'reply_author@example.org') }
+ let(:inline_note_author) { create(:user, username: 'inline_note_author', email: 'inline_note_author@example.org') }
+
+ let(:reply) do
+ instance_double(
+ BitbucketServer::Representation::PullRequestComment,
+ author_email: reply_author.email,
+ author_username: reply_author.username,
+ note: 'I agree',
+ created_at: now,
+ updated_at: now)
+ end
# https://gitlab.com/gitlab-org/gitlab-test/compare/c1acaa58bbcbc3eafe538cb8274ba387047b69f8...5937ac0a7beb003549fc5fd26fc247ad
- inline_note = instance_double(
- BitbucketServer::Representation::PullRequestComment,
- file_type: 'ADDED',
- from_sha: sample.commits.first,
- to_sha: sample.commits.last,
- file_path: '.gitmodules',
- old_pos: nil,
- new_pos: 4,
- note: 'Hello world',
- author_email: 'unknown@gmail.com',
- author_username: 'Superman',
- comments: [reply],
- created_at: now,
- updated_at: now,
- parent_comment: nil)
+ let(:inline_note) do
+ instance_double(
+ BitbucketServer::Representation::PullRequestComment,
+ file_type: 'ADDED',
+ from_sha: sample.commits.first,
+ to_sha: sample.commits.last,
+ file_path: '.gitmodules',
+ old_pos: nil,
+ new_pos: 4,
+ note: 'Hello world',
+ author_email: inline_note_author.email,
+ author_username: inline_note_author.username,
+ comments: [reply],
+ created_at: now,
+ updated_at: now,
+ parent_comment: nil)
+ end
- allow(reply).to receive(:parent_comment).and_return(inline_note)
+ let(:inline_comment) do
+ instance_double(
+ BitbucketServer::Representation::Activity,
+ comment?: true,
+ inline_comment?: true,
+ merge_event?: false,
+ comment: inline_note)
+ end
- inline_comment = instance_double(
- BitbucketServer::Representation::Activity,
- comment?: true,
- inline_comment?: true,
- merge_event?: false,
- comment: inline_note)
+ before do
+ allow(reply).to receive(:parent_comment).and_return(inline_note)
+ allow(subject.client).to receive(:activities).and_return([inline_comment])
+ end
- expect(subject.client).to receive(:activities).and_return([inline_comment])
+ shared_examples 'imports threaded discussions' do
+ it 'imports threaded discussions' do
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.notes.count).to eq(2)
+ expect(merge_request.notes.map(&:discussion_id).uniq.count).to eq(1)
+
+ notes = merge_request.notes.order(:id).to_a
+ start_note = notes.first
+ expect(start_note.type).to eq('DiffNote')
+ expect(start_note.note).to end_with(inline_note.note)
+ expect(start_note.created_at).to eq(inline_note.created_at)
+ expect(start_note.updated_at).to eq(inline_note.updated_at)
+ expect(start_note.position.base_sha).to eq(inline_note.from_sha)
+ expect(start_note.position.start_sha).to eq(inline_note.from_sha)
+ expect(start_note.position.head_sha).to eq(inline_note.to_sha)
+ expect(start_note.position.old_line).to be_nil
+ expect(start_note.position.new_line).to eq(inline_note.new_pos)
+ expect(start_note.author).to eq(inline_note_author)
+
+ reply_note = notes.last
+ # Make sure author and reply context is included
+ expect(reply_note.note).to start_with("> #{inline_note.note}\n\n#{reply.note}")
+ expect(reply_note.author).to eq(reply_author)
+ expect(reply_note.created_at).to eq(reply.created_at)
+ expect(reply_note.updated_at).to eq(reply.created_at)
+ expect(reply_note.position.base_sha).to eq(inline_note.from_sha)
+ expect(reply_note.position.start_sha).to eq(inline_note.from_sha)
+ expect(reply_note.position.head_sha).to eq(inline_note.to_sha)
+ expect(reply_note.position.old_line).to be_nil
+ expect(reply_note.position.new_line).to eq(inline_note.new_pos)
+ end
+ end
- expect { subject.execute }.to change { MergeRequest.count }.by(1)
+ context 'when bitbucket_server_user_mapping_by_username feature flag is disabled' do
+ before do
+ stub_feature_flags(bitbucket_server_user_mapping_by_username: false)
+ end
- merge_request = MergeRequest.first
- expect(merge_request.notes.count).to eq(2)
- expect(merge_request.notes.map(&:discussion_id).uniq.count).to eq(1)
-
- notes = merge_request.notes.order(:id).to_a
- start_note = notes.first
- expect(start_note.type).to eq('DiffNote')
- expect(start_note.note).to end_with(inline_note.note)
- expect(start_note.created_at).to eq(inline_note.created_at)
- expect(start_note.updated_at).to eq(inline_note.updated_at)
- expect(start_note.position.base_sha).to eq(inline_note.from_sha)
- expect(start_note.position.start_sha).to eq(inline_note.from_sha)
- expect(start_note.position.head_sha).to eq(inline_note.to_sha)
- expect(start_note.position.old_line).to be_nil
- expect(start_note.position.new_line).to eq(inline_note.new_pos)
-
- reply_note = notes.last
- # Make sure author and reply context is included
- expect(reply_note.note).to start_with("*By #{reply.author_username} (#{reply.author_email})*\n\n")
- expect(reply_note.note).to end_with("> #{inline_note.note}\n\n#{reply.note}")
- expect(reply_note.author).to eq(project.owner)
- expect(reply_note.created_at).to eq(reply.created_at)
- expect(reply_note.updated_at).to eq(reply.created_at)
- expect(reply_note.position.base_sha).to eq(inline_note.from_sha)
- expect(reply_note.position.start_sha).to eq(inline_note.from_sha)
- expect(reply_note.position.head_sha).to eq(inline_note.to_sha)
- expect(reply_note.position.old_line).to be_nil
- expect(reply_note.position.new_line).to eq(inline_note.new_pos)
+ include_examples 'imports threaded discussions'
+ end
+
+ context 'when bitbucket_server_user_mapping_by_username feature flag is enabled' do
+ before do
+ stub_feature_flags(bitbucket_server_user_mapping_by_username: true)
+ end
+
+ include_examples 'imports threaded discussions' do
+ context 'when username is not present' do
+ before do
+ allow(reply).to receive(:author_username).and_return(nil)
+ allow(inline_note).to receive(:author_username).and_return(nil)
+ end
+
+ it 'maps by email' do
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ notes = MergeRequest.first.notes.order(:id).to_a
+
+ expect(notes.first.author).to eq(inline_note_author)
+ expect(notes.last.author).to eq(reply_author)
+ end
+ end
+ end
+ end
+
+ context 'when user is not found' do
+ before do
+ allow(reply).to receive(:author_username).and_return(nil)
+ allow(reply).to receive(:author_email).and_return(nil)
+ allow(inline_note).to receive(:author_username).and_return(nil)
+ allow(inline_note).to receive(:author_email).and_return(nil)
+ end
+
+ it 'maps importer user' do
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ notes = MergeRequest.first.notes.order(:id).to_a
+
+ expect(notes.first.author).to eq(project_creator)
+ expect(notes.last.author).to eq(project_creator)
+ end
+ end
end
it 'falls back to comments if diff comments fail to validate' do
@@ -312,6 +483,7 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
state: 'merged',
author: 'Test Author',
author_email: project.owner.email,
+ author_username: 'author',
created_at: Time.now,
updated_at: Time.now,
merged?: true)
diff --git a/spec/lib/gitlab/checks/lfs_integrity_spec.rb b/spec/lib/gitlab/checks/lfs_integrity_spec.rb
index 8fec702790c..4583cd72cfd 100644
--- a/spec/lib/gitlab/checks/lfs_integrity_spec.rb
+++ b/spec/lib/gitlab/checks/lfs_integrity_spec.rb
@@ -57,25 +57,5 @@ RSpec.describe Gitlab::Checks::LfsIntegrity do
expect(subject.objects_missing?).to be_falsey
end
end
-
- context 'for forked project', :sidekiq_might_not_need_inline do
- let(:parent_project) { create(:project, :repository) }
- let(:project) { fork_project(parent_project, nil, repository: true) }
-
- before do
- allow(project).to receive(:lfs_enabled?).and_return(true)
- end
-
- it 'is true parent project is missing LFS objects' do
- expect(subject.objects_missing?).to be_truthy
- end
-
- it 'is false parent project already contains LFS objects for the fork' do
- lfs_object = create(:lfs_object, oid: blob_object.lfs_oid)
- create(:lfs_objects_project, project: parent_project, lfs_object: lfs_object)
-
- expect(subject.objects_missing?).to be_falsey
- end
- end
end
end
diff --git a/spec/lib/gitlab/checks/project_moved_spec.rb b/spec/lib/gitlab/checks/project_moved_spec.rb
index e15fa90443b..c7dad0a91d4 100644
--- a/spec/lib/gitlab/checks/project_moved_spec.rb
+++ b/spec/lib/gitlab/checks/project_moved_spec.rb
@@ -57,12 +57,12 @@ RSpec.describe Gitlab::Checks::ProjectMoved, :clean_gitlab_redis_shared_state do
shared_examples 'returns redirect message' do
it do
message = <<~MSG
- Project '#{redirect_path}' was moved to '#{project.full_path}'.
+ Project '#{redirect_path}' was moved to '#{project.full_path}'.
- Please update your Git remote:
+ Please update your Git remote:
- git remote set-url origin #{url_to_repo}
- MSG
+ git remote set-url origin #{url_to_repo}
+ MSG
expect(subject.message).to eq(message)
end
diff --git a/spec/lib/gitlab/checks/snippet_check_spec.rb b/spec/lib/gitlab/checks/snippet_check_spec.rb
index 2c027486bc9..037de8e9369 100644
--- a/spec/lib/gitlab/checks/snippet_check_spec.rb
+++ b/spec/lib/gitlab/checks/snippet_check_spec.rb
@@ -5,10 +5,12 @@ require 'spec_helper'
RSpec.describe Gitlab::Checks::SnippetCheck do
include_context 'change access checks context'
- let(:snippet) { create(:personal_snippet, :repository) }
+ let_it_be(:snippet) { create(:personal_snippet, :repository) }
+
let(:user_access) { Gitlab::UserAccessSnippet.new(user, snippet: snippet) }
+ let(:default_branch) { snippet.default_branch }
- subject { Gitlab::Checks::SnippetCheck.new(changes, logger: logger) }
+ subject { Gitlab::Checks::SnippetCheck.new(changes, default_branch: default_branch, logger: logger) }
describe '#validate!' do
it 'does not raise any error' do
@@ -39,5 +41,13 @@ RSpec.describe Gitlab::Checks::SnippetCheck do
end
end
end
+
+ context 'when default_branch is nil' do
+ let(:default_branch) { nil }
+
+ it 'raises an error' do
+ expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError, 'You can not create or delete branches.')
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/artifact_file_reader_spec.rb b/spec/lib/gitlab/ci/artifact_file_reader_spec.rb
index e982f0eb015..83a37655ea9 100644
--- a/spec/lib/gitlab/ci/artifact_file_reader_spec.rb
+++ b/spec/lib/gitlab/ci/artifact_file_reader_spec.rb
@@ -18,6 +18,17 @@ RSpec.describe Gitlab::Ci::ArtifactFileReader do
expect(YAML.safe_load(subject).keys).to contain_exactly('rspec', 'time', 'custom')
end
+ context 'when FF ci_new_artifact_file_reader is disabled' do
+ before do
+ stub_feature_flags(ci_new_artifact_file_reader: false)
+ end
+
+ it 'returns the content at the path' do
+ is_expected.to be_present
+ expect(YAML.safe_load(subject).keys).to contain_exactly('rspec', 'time', 'custom')
+ end
+ end
+
context 'when path does not exist' do
let(:path) { 'file/does/not/exist.txt' }
let(:expected_error) do
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index ca02eaee0a0..ab760b107f8 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -74,16 +74,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
it { is_expected.to be_falsey }
end
- context 'when config does not contain script' do
- let(:name) { :build }
-
- let(:config) do
- { before_script: "cd ${PROJ_DIR} " }
- end
-
- it { is_expected.to be_truthy }
- end
-
context 'when using the default job without script' do
let(:name) { :default }
let(:config) do
@@ -104,14 +94,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
it { is_expected.to be_truthy }
end
-
- context 'there are no shared keys between jobs and bridges' do
- subject(:shared_values) do
- described_class::ALLOWED_KEYS & Gitlab::Ci::Config::Entry::Bridge::ALLOWED_KEYS
- end
-
- it { is_expected.to be_empty }
- end
end
describe 'validations' do
diff --git a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
index 8561bd330b7..ac6b589ec6b 100644
--- a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
@@ -68,7 +68,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Jobs do
let(:config) { { rspec: nil } }
it 'reports error' do
- expect(entry.errors).to include "jobs config should contain valid jobs"
+ expect(entry.errors).to include 'jobs rspec config should implement a script: or a trigger: keyword'
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb
index 140b3c4f55b..252bda6461d 100644
--- a/spec/lib/gitlab/ci/config/entry/root_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb
@@ -344,9 +344,9 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
end
describe '#errors' do
- it 'reports errors about missing script' do
+ it 'reports errors about missing script or trigger' do
expect(root.errors)
- .to include "root config contains unknown keys: rspec"
+ .to include 'jobs rspec config should implement a script: or a trigger: keyword'
end
end
end
diff --git a/spec/lib/gitlab/ci/config/normalizer/matrix_strategy_spec.rb b/spec/lib/gitlab/ci/config/normalizer/matrix_strategy_spec.rb
index bab604c4504..fbf86927bd9 100644
--- a/spec/lib/gitlab/ci/config/normalizer/matrix_strategy_spec.rb
+++ b/spec/lib/gitlab/ci/config/normalizer/matrix_strategy_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe Gitlab::Ci::Config::Normalizer::MatrixStrategy do
expect(subject.map(&:attributes)).to match_array(
[
{
- name: 'test 1/4',
+ name: 'test: [aws, app1]',
instance: 1,
parallel: { total: 4 },
variables: {
@@ -52,7 +52,7 @@ RSpec.describe Gitlab::Ci::Config::Normalizer::MatrixStrategy do
}
},
{
- name: 'test 2/4',
+ name: 'test: [aws, app2]',
instance: 2,
parallel: { total: 4 },
variables: {
@@ -61,7 +61,7 @@ RSpec.describe Gitlab::Ci::Config::Normalizer::MatrixStrategy do
}
},
{
- name: 'test 3/4',
+ name: 'test: [ovh, app]',
instance: 3,
parallel: { total: 4 },
variables: {
@@ -70,7 +70,7 @@ RSpec.describe Gitlab::Ci::Config::Normalizer::MatrixStrategy do
}
},
{
- name: 'test 4/4',
+ name: 'test: [gcp, app]',
instance: 4,
parallel: { total: 4 },
variables: {
@@ -84,18 +84,7 @@ RSpec.describe Gitlab::Ci::Config::Normalizer::MatrixStrategy do
it 'has parallelized name' do
expect(subject.map(&:name)).to match_array(
- ['test 1/4', 'test 2/4', 'test 3/4', 'test 4/4']
- )
- end
-
- it 'has details' do
- expect(subject.map(&:name_with_details)).to match_array(
- [
- 'test (PROVIDER=aws; STACK=app1)',
- 'test (PROVIDER=aws; STACK=app2)',
- 'test (PROVIDER=gcp; STACK=app)',
- 'test (PROVIDER=ovh; STACK=app)'
- ]
+ ['test: [aws, app1]', 'test: [aws, app2]', 'test: [gcp, app]', 'test: [ovh, app]']
)
end
end
diff --git a/spec/lib/gitlab/ci/config/normalizer_spec.rb b/spec/lib/gitlab/ci/config/normalizer_spec.rb
index 949af8cdc4c..4c19657413c 100644
--- a/spec/lib/gitlab/ci/config/normalizer_spec.rb
+++ b/spec/lib/gitlab/ci/config/normalizer_spec.rb
@@ -178,8 +178,8 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do
{
matrix: [
{
- VAR_1: [1],
- VAR_2: [2, 3]
+ VAR_1: ['A'],
+ VAR_2: %w[B C]
}
]
}
@@ -187,8 +187,8 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do
let(:expanded_job_names) do
[
- 'rspec 1/2',
- 'rspec 2/2'
+ 'rspec: [A, B]',
+ 'rspec: [A, C]'
]
end
@@ -196,21 +196,17 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do
is_expected.not_to include(job_name)
end
- it 'has parallelized jobs' do
- is_expected.to include(*expanded_job_names.map(&:to_sym))
- end
-
it 'sets job instance in options' do
expect(subject.values).to all(include(:instance))
end
it 'sets job variables', :aggregate_failures do
expect(subject.values[0]).to match(
- a_hash_including(variables: { VAR_1: 1, VAR_2: 2, USER_VARIABLE: 'user value' })
+ a_hash_including(variables: { VAR_1: 'A', VAR_2: 'B', USER_VARIABLE: 'user value' })
)
expect(subject.values[1]).to match(
- a_hash_including(variables: { VAR_1: 1, VAR_2: 3, USER_VARIABLE: 'user value' })
+ a_hash_including(variables: { VAR_1: 'A', VAR_2: 'C', USER_VARIABLE: 'user value' })
)
end
@@ -226,6 +222,10 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do
expect(configs).to all(match(a_hash_including(original_config)))
end
+ it 'has parallelized jobs' do
+ is_expected.to include(*expanded_job_names.map(&:to_sym))
+ end
+
it_behaves_like 'parallel dependencies'
it_behaves_like 'parallel needs'
end
@@ -238,5 +238,11 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do
is_expected.to match(config)
end
end
+
+ context 'when jobs config is nil' do
+ let(:config) { nil }
+
+ it { is_expected.to eq({}) }
+ end
end
end
diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb
index 18be9558829..41a45fe4ab7 100644
--- a/spec/lib/gitlab/ci/config_spec.rb
+++ b/spec/lib/gitlab/ci/config_spec.rb
@@ -312,7 +312,7 @@ RSpec.describe Gitlab::Ci::Config do
HEREDOC
end
- it 'raises error YamlProcessor validationError' do
+ it 'raises ConfigError' do
expect { config }.to raise_error(
described_class::ConfigError,
"Included file `invalid` does not have YAML extension!"
@@ -329,7 +329,7 @@ RSpec.describe Gitlab::Ci::Config do
HEREDOC
end
- it 'raises error YamlProcessor validationError' do
+ it 'raises ConfigError' do
expect { config }.to raise_error(
described_class::ConfigError,
'Include `{"remote":"http://url","local":"/local/file.yml"}` needs to match exactly one accessor!'
diff --git a/spec/lib/gitlab/ci/jwt_spec.rb b/spec/lib/gitlab/ci/jwt_spec.rb
index a15f3310dab..9b133efad9c 100644
--- a/spec/lib/gitlab/ci/jwt_spec.rb
+++ b/spec/lib/gitlab/ci/jwt_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe Gitlab::Ci::Jwt do
subject(:payload) { described_class.new(build, ttl: 30).payload }
it 'has correct values for the standard JWT attributes' do
- Timecop.freeze do
+ freeze_time do
now = Time.now.to_i
aggregate_failures do
diff --git a/spec/lib/gitlab/ci/lint_spec.rb b/spec/lib/gitlab/ci/lint_spec.rb
new file mode 100644
index 00000000000..077c0fd3162
--- /dev/null
+++ b/spec/lib/gitlab/ci/lint_spec.rb
@@ -0,0 +1,251 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Lint do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
+ let(:lint) { described_class.new(project: project, current_user: user) }
+
+ describe '#validate' do
+ subject { lint.validate(content, dry_run: dry_run) }
+
+ shared_examples 'content is valid' do
+ let(:content) do
+ <<~YAML
+ build:
+ stage: build
+ before_script:
+ - before_build
+ script: echo
+ environment: staging
+ when: manual
+ rspec:
+ stage: test
+ script: rspec
+ after_script:
+ - after_rspec
+ tags: [docker]
+ YAML
+ end
+
+ it 'returns a valid result', :aggregate_failures do
+ expect(subject).to be_valid
+
+ expect(subject.errors).to be_empty
+ expect(subject.warnings).to be_empty
+ expect(subject.jobs).to be_present
+
+ build_job = subject.jobs.first
+ expect(build_job[:name]).to eq('build')
+ expect(build_job[:stage]).to eq('build')
+ expect(build_job[:before_script]).to eq(['before_build'])
+ expect(build_job[:script]).to eq(['echo'])
+ expect(build_job.fetch(:after_script)).to eq([])
+ expect(build_job[:tag_list]).to eq([])
+ expect(build_job[:environment]).to eq('staging')
+ expect(build_job[:when]).to eq('manual')
+ expect(build_job[:allow_failure]).to eq(true)
+
+ rspec_job = subject.jobs.last
+ expect(rspec_job[:name]).to eq('rspec')
+ expect(rspec_job[:stage]).to eq('test')
+ expect(rspec_job.fetch(:before_script)).to eq([])
+ expect(rspec_job[:script]).to eq(['rspec'])
+ expect(rspec_job[:after_script]).to eq(['after_rspec'])
+ expect(rspec_job[:tag_list]).to eq(['docker'])
+ expect(rspec_job.fetch(:environment)).to be_nil
+ expect(rspec_job[:when]).to eq('on_success')
+ expect(rspec_job[:allow_failure]).to eq(false)
+ end
+ end
+
+ shared_examples 'content with errors and warnings' do
+ context 'when content has errors' do
+ let(:content) do
+ <<~YAML
+ build:
+ invalid: syntax
+ YAML
+ end
+
+ it 'returns a result with errors' do
+ expect(subject).not_to be_valid
+ expect(subject.errors).to include(/jobs build config should implement a script: or a trigger: keyword/)
+ end
+ end
+
+ context 'when content has warnings' do
+ let(:content) do
+ <<~YAML
+ rspec:
+ script: rspec
+ rules:
+ - when: always
+ YAML
+ end
+
+ it 'returns a result with warnings' do
+ expect(subject).to be_valid
+ expect(subject.warnings).to include(/rspec may allow multiple pipelines to run/)
+ end
+ end
+
+ context 'when content has more warnings than max limit' do
+ # content will result in 2 warnings
+ let(:content) do
+ <<~YAML
+ rspec:
+ script: rspec
+ rules:
+ - when: always
+ rspec2:
+ script: rspec
+ rules:
+ - when: always
+ YAML
+ end
+
+ before do
+ stub_const('Gitlab::Ci::Warnings::MAX_LIMIT', 1)
+ end
+
+ it 'returns a result with warnings' do
+ expect(subject).to be_valid
+ expect(subject.warnings.size).to eq(1)
+ end
+ end
+
+ context 'when content has errors and warnings' do
+ let(:content) do
+ <<~YAML
+ rspec:
+ script: rspec
+ rules:
+ - when: always
+ karma:
+ script: karma
+ unknown: key
+ YAML
+ end
+
+ it 'returns a result with errors and warnings' do
+ expect(subject).not_to be_valid
+ expect(subject.errors).to include(/karma config contains unknown keys/)
+ expect(subject.warnings).to include(/rspec may allow multiple pipelines to run/)
+ end
+ end
+ end
+
+ shared_context 'advanced validations' do
+ let(:content) do
+ <<~YAML
+ build:
+ stage: build
+ script: echo
+ rules:
+ - if: '$CI_MERGE_REQUEST_ID'
+ test:
+ stage: test
+ script: echo
+ needs: [build]
+ YAML
+ end
+ end
+
+ context 'when user has permissions to write the ref' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when using default static mode' do
+ let(:dry_run) { false }
+
+ it_behaves_like 'content with errors and warnings'
+
+ it_behaves_like 'content is valid' do
+ it 'includes extra attributes' do
+ subject.jobs.each do |job|
+ expect(job[:only]).to eq(refs: %w[branches tags])
+ expect(job.fetch(:except)).to be_nil
+ end
+ end
+ end
+
+ include_context 'advanced validations' do
+ it 'does not catch advanced logical errors' do
+ expect(subject).to be_valid
+ expect(subject.errors).to be_empty
+ end
+ end
+
+ it 'uses YamlProcessor' do
+ expect(Gitlab::Ci::YamlProcessor)
+ .to receive(:new)
+ .and_call_original
+
+ subject
+ end
+ end
+
+ context 'when using dry run mode' do
+ let(:dry_run) { true }
+
+ it_behaves_like 'content with errors and warnings'
+
+ it_behaves_like 'content is valid' do
+ it 'does not include extra attributes' do
+ subject.jobs.each do |job|
+ expect(job.key?(:only)).to be_falsey
+ expect(job.key?(:except)).to be_falsey
+ end
+ end
+ end
+
+ include_context 'advanced validations' do
+ it 'runs advanced logical validations' do
+ expect(subject).not_to be_valid
+ expect(subject.errors).to eq(["test: needs 'build'"])
+ end
+ end
+
+ it 'uses Ci::CreatePipelineService' do
+ expect(::Ci::CreatePipelineService)
+ .to receive(:new)
+ .and_call_original
+
+ subject
+ end
+ end
+ end
+
+ context 'when user does not have permissions to write the ref' do
+ before do
+ project.add_reporter(user)
+ end
+
+ context 'when using default static mode' do
+ let(:dry_run) { false }
+
+ it_behaves_like 'content is valid'
+ end
+
+ context 'when using dry run mode' do
+ let(:dry_run) { true }
+
+ let(:content) do
+ <<~YAML
+ job:
+ script: echo
+ YAML
+ end
+
+ it 'does not allow validation' do
+ expect(subject).not_to be_valid
+ expect(subject.errors).to include('Insufficient permissions to create a new pipeline')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb
index 8b9de16ce5f..11e3f32c7ce 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs do
let(:command) do
double(:command,
- config_processor: double(:processor,
+ yaml_processor_result: double(:processor,
jobs: { echo: double(:job_echo), rspec: double(:job_rspec) }),
project: project,
chat_data: { command: 'echo' })
@@ -25,7 +25,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs do
subject
- expect(command.config_processor.jobs.keys).to eq([:echo])
+ expect(command.yaml_processor_result.jobs.keys).to eq([:echo])
end
it 'does not remove any jobs for non chat-pipelines' do
@@ -33,7 +33,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs do
subject
- expect(command.config_processor.jobs.keys).to eq([:echo, :rspec])
+ expect(command.yaml_processor_result.jobs.keys).to eq([:echo, :rspec])
end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb
index de580d2e148..e55281f9705 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb
@@ -31,20 +31,20 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Validate::External do
CI_YAML
end
- let(:yaml_processor) do
+ let(:yaml_processor_result) do
::Gitlab::Ci::YamlProcessor.new(
ci_yaml, {
project: project,
sha: pipeline.sha,
user: user
}
- )
+ ).execute
end
let(:save_incompleted) { true }
let(:command) do
Gitlab::Ci::Pipeline::Chain::Command.new(
- project: project, current_user: user, config_processor: yaml_processor, save_incompleted: save_incompleted
+ project: project, current_user: user, yaml_processor_result: yaml_processor_result, save_incompleted: save_incompleted
)
end
@@ -128,7 +128,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Validate::External do
end
describe '#validation_service_payload' do
- subject(:validation_service_payload) { step.send(:validation_service_payload, pipeline, command.config_processor.stages_attributes) }
+ subject(:validation_service_payload) { step.send(:validation_service_payload, pipeline, command.yaml_processor_result.stages_attributes) }
it 'respects the defined schema' do
expect(validation_service_payload).to match_schema('/external_validation')
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb
index 6e242faa885..fc5725a4d17 100644
--- a/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb
@@ -90,24 +90,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexer do
end
with_them do
- context 'when ci_if_parenthesis_enabled is enabled' do
- before do
- stub_feature_flags(ci_if_parenthesis_enabled: true)
- end
-
- it { is_expected.to eq(tokens) }
- end
-
- context 'when ci_if_parenthesis_enabled is disabled' do
- before do
- stub_feature_flags(ci_if_parenthesis_enabled: false)
- end
-
- it do
- expect { subject }
- .to raise_error described_class::SyntaxError
- end
- end
+ it { is_expected.to eq(tokens) }
end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb
index 3394a75ac0a..a02c247925e 100644
--- a/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb
@@ -3,10 +3,6 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Pipeline::Expression::Parser do
- before do
- stub_feature_flags(ci_if_parenthesis_enabled: true)
- end
-
describe '#tree' do
context 'validates simple operators' do
using RSpec::Parameterized::TableSyntax
@@ -31,36 +27,15 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Parser do
context 'when combining && and OR operators' do
subject { described_class.seed('$VAR1 == "a" || $VAR2 == "b" && $VAR3 == "c" || $VAR4 == "d" && $VAR5 == "e"').tree }
- context 'when parenthesis engine is enabled' do
- before do
- stub_feature_flags(ci_if_parenthesis_enabled: true)
- end
-
- it 'returns operations in a correct order' do
- expect(subject.inspect)
- .to eq('or(or(equals($VAR1, "a"), and(equals($VAR2, "b"), equals($VAR3, "c"))), and(equals($VAR4, "d"), equals($VAR5, "e")))')
- end
- end
-
- context 'when parenthesis engine is disabled (legacy)' do
- before do
- stub_feature_flags(ci_if_parenthesis_enabled: false)
- end
-
- it 'returns operations in a invalid order' do
- expect(subject.inspect)
- .to eq('or(equals($VAR1, "a"), and(equals($VAR2, "b"), or(equals($VAR3, "c"), and(equals($VAR4, "d"), equals($VAR5, "e")))))')
- end
+ it 'returns operations in a correct order' do
+ expect(subject.inspect)
+ .to eq('or(or(equals($VAR1, "a"), and(equals($VAR2, "b"), equals($VAR3, "c"))), and(equals($VAR4, "d"), equals($VAR5, "e")))')
end
end
context 'when using parenthesis' do
subject { described_class.seed('(($VAR1 == "a" || $VAR2 == "b") && $VAR3 == "c" || $VAR4 == "d") && $VAR5 == "e"').tree }
- before do
- stub_feature_flags(ci_if_parenthesis_enabled: true)
- end
-
it 'returns operations in a correct order' do
expect(subject.inspect)
.to eq('and(or(and(or(equals($VAR1, "a"), equals($VAR2, "b")), equals($VAR3, "c")), equals($VAR4, "d")), equals($VAR5, "e"))')
@@ -96,38 +71,21 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Parser do
end
context 'when parenthesis are unmatched' do
- context 'when parenthesis engine is enabled' do
- before do
- stub_feature_flags(ci_if_parenthesis_enabled: true)
- end
-
- where(:expression) do
- [
- '$VAR == (',
- '$VAR2 == ("aa"',
- '$VAR2 == ("aa"))',
- '$VAR2 == "aa")',
- '(($VAR2 == "aa")',
- '($VAR2 == "aa"))'
- ]
- end
-
- with_them do
- it 'raises a ParseError' do
- expect { described_class.seed(expression).tree }
- .to raise_error Gitlab::Ci::Pipeline::Expression::Parser::ParseError
- end
- end
+ where(:expression) do
+ [
+ '$VAR == (',
+ '$VAR2 == ("aa"',
+ '$VAR2 == ("aa"))',
+ '$VAR2 == "aa")',
+ '(($VAR2 == "aa")',
+ '($VAR2 == "aa"))'
+ ]
end
- context 'when parenthesis engine is disabled' do
- before do
- stub_feature_flags(ci_if_parenthesis_enabled: false)
- end
-
- it 'raises an SyntaxError' do
- expect { described_class.seed('$VAR == (').tree }
- .to raise_error Gitlab::Ci::Pipeline::Expression::Lexer::SyntaxError
+ with_them do
+ it 'raises a ParseError' do
+ expect { described_class.seed(expression).tree }
+ .to raise_error Gitlab::Ci::Pipeline::Expression::Parser::ParseError
end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
index 733ab30132d..34df0e86a18 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
@@ -931,47 +931,30 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
context 'when using 101 needs' do
let(:needs_count) { 101 }
- context 'when ci_plan_needs_size_limit is disabled' do
+ it "returns an error" do
+ expect(subject.errors).to contain_exactly(
+ "rspec: one job can only need 50 others, but you have listed 101. See needs keyword documentation for more details")
+ end
+
+ context 'when ci_needs_size_limit is set to 100' do
before do
- stub_feature_flags(ci_plan_needs_size_limit: false)
+ project.actual_limits.update!(ci_needs_size_limit: 100)
end
it "returns an error" do
expect(subject.errors).to contain_exactly(
- "rspec: one job can only need 10 others, but you have listed 101. See needs keyword documentation for more details")
+ "rspec: one job can only need 100 others, but you have listed 101. See needs keyword documentation for more details")
end
end
- context 'when ci_plan_needs_size_limit is enabled' do
+ context 'when ci_needs_size_limit is set to 0' do
before do
- stub_feature_flags(ci_plan_needs_size_limit: true)
+ project.actual_limits.update!(ci_needs_size_limit: 0)
end
it "returns an error" do
expect(subject.errors).to contain_exactly(
- "rspec: one job can only need 50 others, but you have listed 101. See needs keyword documentation for more details")
- end
-
- context 'when ci_needs_size_limit is set to 100' do
- before do
- project.actual_limits.update!(ci_needs_size_limit: 100)
- end
-
- it "returns an error" do
- expect(subject.errors).to contain_exactly(
- "rspec: one job can only need 100 others, but you have listed 101. See needs keyword documentation for more details")
- end
- end
-
- context 'when ci_needs_size_limit is set to 0' do
- before do
- project.actual_limits.update!(ci_needs_size_limit: 0)
- end
-
- it "returns an error" do
- expect(subject.errors).to contain_exactly(
- "rspec: one job can only need 0 others, but you have listed 101. See needs keyword documentation for more details")
- end
+ "rspec: one job can only need 0 others, but you have listed 101. See needs keyword documentation for more details")
end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline_object_hierarchy_spec.rb b/spec/lib/gitlab/ci/pipeline_object_hierarchy_spec.rb
new file mode 100644
index 00000000000..89602fe79d1
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline_object_hierarchy_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::PipelineObjectHierarchy do
+ include Ci::SourcePipelineHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:ancestor) { create(:ci_pipeline, project: project) }
+ let_it_be(:parent) { create(:ci_pipeline, project: project) }
+ let_it_be(:child) { create(:ci_pipeline, project: project) }
+ let_it_be(:cousin_parent) { create(:ci_pipeline, project: project) }
+ let_it_be(:cousin) { create(:ci_pipeline, project: project) }
+ let_it_be(:triggered_pipeline) { create(:ci_pipeline) }
+
+ before_all do
+ create_source_pipeline(ancestor, parent)
+ create_source_pipeline(ancestor, cousin_parent)
+ create_source_pipeline(parent, child)
+ create_source_pipeline(cousin_parent, cousin)
+ create_source_pipeline(child, triggered_pipeline)
+ end
+
+ describe '#base_and_ancestors' do
+ it 'includes the base and its ancestors' do
+ relation = described_class.new(::Ci::Pipeline.where(id: parent.id),
+ options: { same_project: true }).base_and_ancestors
+
+ expect(relation).to contain_exactly(ancestor, parent)
+ end
+
+ it 'can find ancestors upto a certain level' do
+ relation = described_class.new(::Ci::Pipeline.where(id: child.id),
+ options: { same_project: true }).base_and_ancestors(upto: ancestor.id)
+
+ expect(relation).to contain_exactly(parent, child)
+ end
+
+ describe 'hierarchy_order option' do
+ let(:relation) do
+ described_class.new(::Ci::Pipeline.where(id: child.id),
+ options: { same_project: true }).base_and_ancestors(hierarchy_order: hierarchy_order)
+ end
+
+ context ':asc' do
+ let(:hierarchy_order) { :asc }
+
+ it 'orders by child to ancestor' do
+ expect(relation).to eq([child, parent, ancestor])
+ end
+ end
+
+ context ':desc' do
+ let(:hierarchy_order) { :desc }
+
+ it 'orders by ancestor to child' do
+ expect(relation).to eq([ancestor, parent, child])
+ end
+ end
+ end
+ end
+
+ describe '#base_and_descendants' do
+ it 'includes the base and its descendants' do
+ relation = described_class.new(::Ci::Pipeline.where(id: parent.id),
+ options: { same_project: true }).base_and_descendants
+
+ expect(relation).to contain_exactly(parent, child)
+ end
+
+ context 'when with_depth is true' do
+ let(:relation) do
+ described_class.new(::Ci::Pipeline.where(id: ancestor.id),
+ options: { same_project: true }).base_and_descendants(with_depth: true)
+ end
+
+ it 'includes depth in the results' do
+ object_depths = {
+ ancestor.id => 1,
+ parent.id => 2,
+ cousin_parent.id => 2,
+ child.id => 3,
+ cousin.id => 3
+ }
+
+ relation.each do |object|
+ expect(object.depth).to eq(object_depths[object.id])
+ end
+ end
+ end
+ end
+
+ describe '#all_objects' do
+ it 'includes its ancestors and descendants' do
+ relation = described_class.new(::Ci::Pipeline.where(id: parent.id),
+ options: { same_project: true }).all_objects
+
+ expect(relation).to contain_exactly(ancestor, parent, child)
+ end
+
+ it 'returns all family tree' do
+ relation = described_class.new(
+ ::Ci::Pipeline.where(id: child.id),
+ described_class.new(::Ci::Pipeline.where(id: child.id), options: { same_project: true }).base_and_ancestors,
+ options: { same_project: true }
+ ).all_objects
+
+ expect(relation).to contain_exactly(ancestor, parent, cousin_parent, child, cousin)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/reports/test_case_spec.rb b/spec/lib/gitlab/ci/reports/test_case_spec.rb
index 8882defbd9e..7fb208213c1 100644
--- a/spec/lib/gitlab/ci/reports/test_case_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_case_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Reports::TestCase do
describe '#initialize' do
- let(:test_case) { described_class.new(params)}
+ let(:test_case) { described_class.new(params) }
context 'when both classname and name are given' do
context 'when test case is passed' do
@@ -62,7 +62,9 @@ RSpec.describe Gitlab::Ci::Reports::TestCase do
end
context 'when attachment is present' do
- let(:attachment_test_case) { build(:test_case, :failed_with_attachment) }
+ let_it_be(:job) { create(:ci_build) }
+
+ let(:attachment_test_case) { build(:test_case, :failed_with_attachment, job: job) }
it "initializes the attachment if present" do
expect(attachment_test_case.attachment).to eq("some/path.png")
diff --git a/spec/lib/gitlab/ci/reports/test_suite_spec.rb b/spec/lib/gitlab/ci/reports/test_suite_spec.rb
index fbe3473f6b0..15fa78444e5 100644
--- a/spec/lib/gitlab/ci/reports/test_suite_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_suite_spec.rb
@@ -176,6 +176,37 @@ RSpec.describe Gitlab::Ci::Reports::TestSuite do
end
end
+ describe '#sorted' do
+ subject { test_suite.sorted }
+
+ context 'when there are multiple failed test cases' do
+ before do
+ test_suite.add_test_case(create_test_case_rspec_failed('test_spec_1', 1.11))
+ test_suite.add_test_case(create_test_case_rspec_failed('test_spec_2', 4.44))
+ end
+
+ it 'returns test cases sorted by execution time desc' do
+ expect(subject.test_cases['failed'].each_value.first.execution_time).to eq(4.44)
+ expect(subject.test_cases['failed'].values.second.execution_time).to eq(1.11)
+ end
+ end
+
+ context 'when there are multiple test cases' do
+ let(:status_ordered) { %w(error failed success skipped) }
+
+ before do
+ test_suite.add_test_case(test_case_success)
+ test_suite.add_test_case(test_case_failed)
+ test_suite.add_test_case(test_case_error)
+ test_suite.add_test_case(test_case_skipped)
+ end
+
+ it 'returns test cases sorted by status' do
+ expect(subject.test_cases.keys).to eq(status_ordered)
+ end
+ end
+ end
+
Gitlab::Ci::Reports::TestCase::STATUS_TYPES.each do |status_type|
describe "##{status_type}" do
subject { test_suite.public_send("#{status_type}") }
diff --git a/spec/lib/gitlab/ci/status/bridge/common_spec.rb b/spec/lib/gitlab/ci/status/bridge/common_spec.rb
new file mode 100644
index 00000000000..92600b21afc
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/bridge/common_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Status::Bridge::Common do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:bridge) { create(:ci_bridge) }
+ let_it_be(:downstream_pipeline) { create(:ci_pipeline) }
+
+ before_all do
+ create(:ci_sources_pipeline,
+ source_pipeline: bridge.pipeline,
+ source_project: bridge.pipeline.project,
+ source_job: bridge,
+ pipeline: downstream_pipeline,
+ project: downstream_pipeline.project)
+ end
+
+ subject do
+ Gitlab::Ci::Status::Core
+ .new(bridge, user)
+ .extend(described_class)
+ end
+
+ describe '#details_path' do
+ context 'when user has access to read downstream pipeline' do
+ before do
+ downstream_pipeline.project.add_developer(user)
+ end
+
+ it { expect(subject).to have_details }
+ it { expect(subject.details_path).to include "pipelines/#{downstream_pipeline.id}" }
+
+ context 'when ci_bridge_pipeline_details is disabled' do
+ before do
+ stub_feature_flags(ci_bridge_pipeline_details: false)
+ end
+
+ it { expect(subject).not_to have_details }
+ it { expect(subject.details_path).to be_nil }
+ end
+ end
+
+ context 'when user does not have access to read downstream pipeline' do
+ it { expect(subject).not_to have_details }
+ it { expect(subject.details_path).to be_nil }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/status/composite_spec.rb b/spec/lib/gitlab/ci/status/composite_spec.rb
index e1dcd05373f..bcfb9f19792 100644
--- a/spec/lib/gitlab/ci/status/composite_spec.rb
+++ b/spec/lib/gitlab/ci/status/composite_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe Gitlab::Ci::Status::Composite do
shared_examples 'compares status and warnings' do
let(:composite_status) do
- described_class.new(all_statuses)
+ described_class.new(all_statuses, dag: dag)
end
it 'returns status and warnings?' do
@@ -30,21 +30,29 @@ RSpec.describe Gitlab::Ci::Status::Composite do
end
context 'allow_failure: false' do
- where(:build_statuses, :result, :has_warnings) do
- %i(skipped) | 'skipped' | false
- %i(skipped success) | 'success' | false
- %i(created) | 'created' | false
- %i(preparing) | 'preparing' | false
- %i(canceled success skipped) | 'canceled' | false
- %i(pending created skipped) | 'pending' | false
- %i(pending created skipped success) | 'running' | false
- %i(running created skipped success) | 'running' | false
- %i(success waiting_for_resource) | 'waiting_for_resource' | false
- %i(success manual) | 'manual' | false
- %i(success scheduled) | 'scheduled' | false
- %i(created preparing) | 'preparing' | false
- %i(created success pending) | 'running' | false
- %i(skipped success failed) | 'failed' | false
+ where(:build_statuses, :dag, :result, :has_warnings) do
+ %i(skipped) | false | 'skipped' | false
+ %i(skipped success) | false | 'success' | false
+ %i(skipped success) | true | 'skipped' | false
+ %i(created) | false | 'created' | false
+ %i(preparing) | false | 'preparing' | false
+ %i(canceled success skipped) | false | 'canceled' | false
+ %i(canceled success skipped) | true | 'skipped' | false
+ %i(pending created skipped) | false | 'pending' | false
+ %i(pending created skipped success) | false | 'running' | false
+ %i(running created skipped success) | false | 'running' | false
+ %i(pending created skipped) | true | 'skipped' | false
+ %i(pending created skipped success) | true | 'skipped' | false
+ %i(running created skipped success) | true | 'skipped' | false
+ %i(success waiting_for_resource) | false | 'waiting_for_resource' | false
+ %i(success manual) | false | 'manual' | false
+ %i(success scheduled) | false | 'scheduled' | false
+ %i(created preparing) | false | 'preparing' | false
+ %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
end
with_them do
@@ -57,11 +65,12 @@ RSpec.describe Gitlab::Ci::Status::Composite do
end
context 'allow_failure: true' do
- where(:build_statuses, :result, :has_warnings) do
- %i(manual) | 'skipped' | false
- %i(skipped failed) | 'success' | true
- %i(created failed) | 'created' | true
- %i(preparing manual) | 'preparing' | false
+ where(:build_statuses, :dag, :result, :has_warnings) do
+ %i(manual) | false | 'skipped' | false
+ %i(skipped failed) | false | 'success' | true
+ %i(skipped failed) | true | 'skipped' | true
+ %i(created failed) | false | 'created' | true
+ %i(preparing manual) | false | 'preparing' | false
end
with_them do
diff --git a/spec/lib/gitlab/ci/templates/templates_spec.rb b/spec/lib/gitlab/ci/templates/templates_spec.rb
index def4d1b3bf6..768256ee6b3 100644
--- a/spec/lib/gitlab/ci/templates/templates_spec.rb
+++ b/spec/lib/gitlab/ci/templates/templates_spec.rb
@@ -3,21 +3,21 @@
require 'spec_helper'
RSpec.describe 'CI YML Templates' do
- subject { Gitlab::Ci::YamlProcessor.new(content) }
+ subject { Gitlab::Ci::YamlProcessor.new(content).execute }
let(:all_templates) { Gitlab::Template::GitlabCiYmlTemplate.all.map(&:full_name) }
- let(:disabled_templates) do
- Gitlab::Template::GitlabCiYmlTemplate.disabled_templates.map do |template|
- template + Gitlab::Template::GitlabCiYmlTemplate.extension
+ let(:excluded_templates) do
+ all_templates.select do |name|
+ Gitlab::Template::GitlabCiYmlTemplate.excluded_patterns.any? { |pattern| pattern.match?(name) }
end
end
- context 'included in a CI YAML configuration' do
+ context 'when including available templates in a CI YAML configuration' do
using RSpec::Parameterized::TableSyntax
where(:template_name) do
- all_templates - disabled_templates
+ all_templates - excluded_templates
end
with_them do
@@ -33,7 +33,7 @@ RSpec.describe 'CI YML Templates' do
end
it 'is valid' do
- expect { subject }.not_to raise_error
+ expect(subject).to be_valid
end
it 'require default stages to be included' do
@@ -41,4 +41,29 @@ RSpec.describe 'CI YML Templates' do
end
end
end
+
+ context 'when including unavailable templates in a CI YAML configuration' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:template_name) do
+ excluded_templates
+ end
+
+ with_them do
+ let(:content) do
+ <<~EOS
+ include:
+ - template: #{template_name}
+
+ concrete_build_implemented_by_a_user:
+ stage: test
+ script: do something
+ EOS
+ end
+
+ it 'is not valid' do
+ expect(subject).not_to be_valid
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb
index e28469c9404..d65b6fb41f6 100644
--- a/spec/lib/gitlab/ci/trace/stream_spec.rb
+++ b/spec/lib/gitlab/ci/trace/stream_spec.rb
@@ -151,6 +151,28 @@ RSpec.describe Gitlab::Ci::Trace::Stream, :clean_gitlab_redis_cache do
it_behaves_like 'appends'
end
+
+ describe 'metrics' do
+ let(:metrics) { spy('metrics') }
+ let(:io) { StringIO.new }
+ let(:stream) { described_class.new(metrics) { io } }
+
+ it 'increments trace streamed operation' do
+ stream.append(+'123456', 0)
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :streamed)
+ end
+
+ it 'increments trace bytes counter' do
+ stream.append(+'123456', 0)
+
+ expect(metrics)
+ .to have_received(:increment_trace_bytes)
+ .with(6)
+ end
+ end
end
describe '#set' do
diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb
index 85edf27d3e7..171877dbaee 100644
--- a/spec/lib/gitlab/ci/trace_spec.rb
+++ b/spec/lib/gitlab/ci/trace_spec.rb
@@ -11,6 +11,29 @@ RSpec.describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state do
it { expect(trace).to delegate_method(:old_trace).to(:job) }
end
+ context 'when trace is migrated to object storage' do
+ let!(:job) { create(:ci_build, :trace_artifact) }
+ let!(:artifact1) { job.job_artifacts_trace }
+ let!(:artifact2) { job.reload.job_artifacts_trace }
+ let(:test_data) { "hello world" }
+
+ before do
+ stub_artifacts_object_storage
+
+ artifact1.file.migrate!(ObjectStorage::Store::REMOTE)
+ end
+
+ it 'reloads the trace after is it migrated' do
+ stub_const('Gitlab::HttpIO::BUFFER_SIZE', test_data.length)
+
+ expect_next_instance_of(Gitlab::HttpIO) do |http_io|
+ expect(http_io).to receive(:get_chunk).and_return(test_data, "")
+ end
+
+ expect(artifact2.job.trace.raw).to eq(test_data)
+ end
+ end
+
context 'when live trace feature is disabled' do
before do
stub_feature_flags(ci_enable_live_trace: false)
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index 1c81cc83cd1..d596494a987 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -7,10 +7,16 @@ module Gitlab
RSpec.describe YamlProcessor do
include StubRequests
- subject { described_class.new(config, user: nil) }
+ subject { described_class.new(config, user: nil).execute }
+
+ shared_examples 'returns errors' do |error_message|
+ it 'adds a message when an error is encountered' do
+ expect(subject.errors).to include(error_message)
+ end
+ end
describe '#build_attributes' do
- subject { described_class.new(config, user: nil).build_attributes(:rspec) }
+ subject { described_class.new(config, user: nil).execute.build_attributes(:rspec) }
describe 'attributes list' do
let(:config) do
@@ -92,7 +98,7 @@ module Gitlab
config = YAML.dump({ default: { tags: %w[A B] },
rspec: { script: "rspec" } })
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first).to eq({
@@ -139,7 +145,7 @@ module Gitlab
config = YAML.dump({ default: { interruptible: true },
rspec: { script: "rspec" } })
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first).to eq({
@@ -345,9 +351,7 @@ module Gitlab
EOYML
end
- it 'parses the workflow:rules configuration' do
- expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'workflow config contains unknown keys: variables')
- end
+ it_behaves_like 'returns errors', 'workflow config contains unknown keys: variables'
end
context 'with rules and variables' do
@@ -470,12 +474,11 @@ module Gitlab
end
it 'is propagated all the way up into the raised exception' do
- expect { subject }.to raise_error do |error|
- expect(error).to be_a(described_class::ValidationError)
- expect(error.message).to eq('jobs:invalid:artifacts config should be a hash')
- expect(error.warnings).to contain_exactly(/jobs:rspec may allow multiple pipelines to run/)
- end
+ expect(subject).not_to be_valid
+ expect(subject.warnings).to contain_exactly(/jobs:rspec may allow multiple pipelines to run/)
end
+
+ it_behaves_like 'returns errors', 'jobs:invalid:artifacts config should be a hash'
end
context 'when error is raised before composing the config' do
@@ -489,23 +492,18 @@ module Gitlab
EOYML
end
- it 'raises an exception with empty warnings array' do
- expect { subject }.to raise_error do |error|
- expect(error).to be_a(described_class::ValidationError)
- expect(error.message).to eq('Local file `unknown/file.yml` does not have project!')
- expect(error.warnings).to be_empty
- end
+ it 'has empty warnings' do
+ expect(subject.warnings).to be_empty
end
+
+ it_behaves_like 'returns errors', 'Local file `unknown/file.yml` does not have project!'
end
context 'when error is raised after composing the config with warnings' do
shared_examples 'has warnings and expected error' do |error_message|
- it 'raises an exception including warnings' do
- expect { subject }.to raise_error do |error|
- expect(error).to be_a(described_class::ValidationError)
- expect(error.message).to match(error_message)
- expect(error.warnings).to be_present
- end
+ it 'returns errors and warnings', :aggregate_failures do
+ expect(subject.errors).to include(error_message)
+ expect(subject.warnings).to be_present
end
end
@@ -585,72 +583,56 @@ module Gitlab
describe 'only / except policies validations' do
context 'when `only` has an invalid value' do
let(:config) { { rspec: { script: "rspec", type: "test", only: only } } }
- let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
+
+ subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
context 'when it is integer' do
let(:only) { 1 }
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:only has to be either an array of conditions or a hash')
- end
+ it_behaves_like 'returns errors', 'jobs:rspec:only has to be either an array of conditions or a hash'
end
context 'when it is an array of integers' do
let(:only) { [1, 1] }
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:only config should be an array of strings or regexps')
- end
+ it_behaves_like 'returns errors', 'jobs:rspec:only config should be an array of strings or regexps'
end
context 'when it is invalid regex' do
let(:only) { ["/*invalid/"] }
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:only config should be an array of strings or regexps')
- end
+ it_behaves_like 'returns errors', 'jobs:rspec:only config should be an array of strings or regexps'
end
end
context 'when `except` has an invalid value' do
let(:config) { { rspec: { script: "rspec", except: except } } }
- let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
+
+ subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
context 'when it is integer' do
let(:except) { 1 }
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:except has to be either an array of conditions or a hash')
- end
+ it_behaves_like 'returns errors', 'jobs:rspec:except has to be either an array of conditions or a hash'
end
context 'when it is an array of integers' do
let(:except) { [1, 1] }
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:except config should be an array of strings or regexps')
- end
+ it_behaves_like 'returns errors', 'jobs:rspec:except config should be an array of strings or regexps'
end
context 'when it is invalid regex' do
let(:except) { ["/*invalid/"] }
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:except config should be an array of strings or regexps')
- end
+ it_behaves_like 'returns errors', 'jobs:rspec:except config should be an array of strings or regexps'
end
end
end
describe "Scripts handling" do
let(:config_data) { YAML.dump(config) }
- let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config_data) }
+ let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config_data).execute }
subject { config_processor.stage_builds_attributes('test').first }
@@ -819,7 +801,7 @@ module Gitlab
before_script: ["pwd"],
rspec: { script: "rspec" } })
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first).to eq({
@@ -852,7 +834,7 @@ module Gitlab
command: ["/usr/local/bin/init", "run"] }, "docker:dind"],
script: "rspec" } })
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first).to eq({
@@ -883,7 +865,7 @@ module Gitlab
before_script: ["pwd"],
rspec: { script: "rspec" } })
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first).to eq({
@@ -910,7 +892,7 @@ module Gitlab
before_script: ["pwd"],
rspec: { image: "ruby:2.5", services: ["postgresql", "docker:dind"], script: "rspec" } })
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first).to eq({
@@ -934,9 +916,9 @@ module Gitlab
end
describe 'Variables' do
- let(:config_processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
+ subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
- subject { config_processor.builds.first[:yaml_variables] }
+ let(:build_variables) { subject.builds.first[:yaml_variables] }
context 'when global variables are defined' do
let(:variables) do
@@ -952,7 +934,7 @@ module Gitlab
end
it 'returns global variables' do
- expect(subject).to contain_exactly(
+ expect(build_variables).to contain_exactly(
{ key: 'VAR1', value: 'value1', public: true },
{ key: 'VAR2', value: 'value2', public: true }
)
@@ -980,7 +962,7 @@ module Gitlab
let(:inherit) { }
it 'returns all unique variables' do
- expect(subject).to contain_exactly(
+ expect(build_variables).to contain_exactly(
{ key: 'VAR4', value: 'global4', public: true },
{ key: 'VAR3', value: 'global3', public: true },
{ key: 'VAR1', value: 'value1', public: true },
@@ -993,7 +975,7 @@ module Gitlab
let(:inherit) { { variables: false } }
it 'does not inherit variables' do
- expect(subject).to contain_exactly(
+ expect(build_variables).to contain_exactly(
{ key: 'VAR1', value: 'value1', public: true },
{ key: 'VAR2', value: 'value2', public: true }
)
@@ -1004,7 +986,7 @@ module Gitlab
let(:inherit) { { variables: %w[VAR1 VAR4] } }
it 'returns all unique variables and inherits only specified variables' do
- expect(subject).to contain_exactly(
+ expect(build_variables).to contain_exactly(
{ key: 'VAR4', value: 'global4', public: true },
{ key: 'VAR1', value: 'value1', public: true },
{ key: 'VAR2', value: 'value2', public: true }
@@ -1027,7 +1009,7 @@ module Gitlab
end
it 'returns job variables' do
- expect(subject).to contain_exactly(
+ expect(build_variables).to contain_exactly(
{ key: 'VAR1', value: 'value1', public: true },
{ key: 'VAR2', value: 'value2', public: true }
)
@@ -1040,11 +1022,7 @@ module Gitlab
%w(VAR1 value1 VAR2 value2)
end
- it 'raises error' do
- expect { subject }
- .to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- /jobs:rspec:variables config should be a hash of key value pairs/)
- end
+ it_behaves_like 'returns errors', /jobs:rspec:variables config should be a hash of key value pairs/
end
context 'when variables key defined but value not specified' do
@@ -1057,8 +1035,8 @@ module Gitlab
# When variables config is empty, we assume this is a valid
# configuration, see issue #18775
#
- expect(subject).to be_an_instance_of(Array)
- expect(subject).to be_empty
+ expect(build_variables).to be_an_instance_of(Array)
+ expect(build_variables).to be_empty
end
end
end
@@ -1073,14 +1051,14 @@ module Gitlab
end
it 'returns empty array' do
- expect(subject).to be_an_instance_of(Array)
- expect(subject).to be_empty
+ expect(build_variables).to be_an_instance_of(Array)
+ expect(build_variables).to be_empty
end
end
end
context 'when using `extends`' do
- let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) }
+ let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config).execute }
subject { config_processor.builds.first }
@@ -1142,31 +1120,25 @@ module Gitlab
}
end
- subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config), opts) }
+ subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config), opts).execute }
context "when validating a ci config file with no project context" do
context "when a single string is provided" do
let(:include_content) { "/local.gitlab-ci.yml" }
- it "returns a validation error" do
- expect { subject }.to raise_error /does not have project/
- end
+ it_behaves_like 'returns errors', /does not have project/
end
context "when an array is provided" do
let(:include_content) { ["/local.gitlab-ci.yml"] }
- it "returns a validation error" do
- expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, /does not have project/)
- end
+ it_behaves_like 'returns errors', /does not have project/
end
context "when an array of wrong keyed object is provided" do
let(:include_content) { [{ yolo: "/local.gitlab-ci.yml" }] }
- it "returns a validation error" do
- expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError)
- end
+ it_behaves_like 'returns errors', /needs to match exactly one accessor/
end
context "when an array of mixed typed objects is provided" do
@@ -1185,17 +1157,13 @@ module Gitlab
body: 'prepare: { script: ls -al }')
end
- it "does not return any error" do
- expect { subject }.not_to raise_error
- end
+ it { is_expected.to be_valid }
end
context "when the include type is incorrect" do
let(:include_content) { { name: "/local.gitlab-ci.yml" } }
- it "returns an invalid configuration error" do
- expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError)
- end
+ it_behaves_like 'returns errors', /needs to match exactly one accessor/
end
end
@@ -1210,18 +1178,11 @@ module Gitlab
.and_return(YAML.dump({ job1: { script: 'hello' } }))
end
- it "does not return an error" do
- expect { subject }.not_to raise_error
- end
+ it { is_expected.to be_valid }
end
context "when the included internal file is not present" do
- it "returns an error with missing file details" do
- expect { subject }.to raise_error(
- Gitlab::Ci::YamlProcessor::ValidationError,
- "Local file `#{include_content}` does not exist!"
- )
- end
+ it_behaves_like 'returns errors', "Local file `/local.gitlab-ci.yml` does not exist!"
end
end
end
@@ -1233,7 +1194,7 @@ module Gitlab
rspec: { script: 'rspec', when: when_state }
})
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
builds = config_processor.stage_builds_attributes("test")
expect(builds.size).to eq(1)
@@ -1243,13 +1204,14 @@ module Gitlab
context 'delayed' do
context 'with start_in' do
- it 'creates one build and sets when:' do
- config = YAML.dump({
+ let(:config) do
+ YAML.dump({
rspec: { script: 'rspec', when: 'delayed', start_in: '1 hour' }
})
+ end
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
- builds = config_processor.stage_builds_attributes("test")
+ it 'creates one build and sets when:' do
+ builds = subject.stage_builds_attributes("test")
expect(builds.size).to eq(1)
expect(builds.first[:when]).to eq('delayed')
@@ -1258,15 +1220,13 @@ module Gitlab
end
context 'without start_in' do
- it 'raises an error' do
- config = YAML.dump({
+ let(:config) do
+ YAML.dump({
rspec: { script: 'rspec', when: 'delayed' }
})
-
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(YamlProcessor::ValidationError, /start in should be a duration/)
end
+
+ it_behaves_like 'returns errors', /start in should be a duration/
end
end
end
@@ -1278,7 +1238,7 @@ module Gitlab
variables: { 'VAR1' => 1 } })
end
- let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) }
+ let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config).execute }
let(:builds) { config_processor.stage_builds_attributes('test') }
context 'when job is parallelized' do
@@ -1377,16 +1337,13 @@ module Gitlab
describe 'cache' do
context 'when cache definition has unknown keys' do
- it 'raises relevant validation error' do
- config = YAML.dump(
+ let(:config) do
+ YAML.dump(
{ cache: { untracked: true, invalid: 'key' },
rspec: { script: 'rspec' } })
-
- expect { Gitlab::Ci::YamlProcessor.new(config) }.to raise_error(
- Gitlab::Ci::YamlProcessor::ValidationError,
- 'cache config contains unknown keys: invalid'
- )
end
+
+ it_behaves_like 'returns errors', 'cache config contains unknown keys: invalid'
end
it "returns cache when defined globally" do
@@ -1397,7 +1354,7 @@ module Gitlab
}
})
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq(
@@ -1419,7 +1376,7 @@ module Gitlab
}
})
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq(
@@ -1438,7 +1395,7 @@ module Gitlab
}
})
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes('test').size).to eq(1)
expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq(
@@ -1461,7 +1418,7 @@ module Gitlab
}
)
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes('test').size).to eq(1)
expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq(
@@ -1484,7 +1441,7 @@ module Gitlab
}
)
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes('test').size).to eq(1)
expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq(
@@ -1504,7 +1461,7 @@ module Gitlab
}
})
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq(
@@ -1534,7 +1491,7 @@ module Gitlab
}
})
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
expect(config_processor.stage_builds_attributes("test").first).to eq({
@@ -1570,7 +1527,7 @@ module Gitlab
}
})
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
builds = config_processor.stage_builds_attributes("test")
expect(builds.size).to eq(1)
@@ -1586,7 +1543,7 @@ module Gitlab
}
})
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
builds = config_processor.stage_builds_attributes("test")
expect(builds.size).to eq(1)
@@ -1594,17 +1551,19 @@ module Gitlab
end
end
- it "gracefully handles errors in artifacts type" do
- config = <<~YAML
- test:
- script:
- - echo "Hello world"
- artifacts:
- - paths:
- - test/
- YAML
+ context 'when artifacts syntax is wrong' do
+ let(:config) do
+ <<~YAML
+ test:
+ script:
+ - echo "Hello world"
+ artifacts:
+ - paths:
+ - test/
+ YAML
+ end
- expect { described_class.new(config) }.to raise_error(described_class::ValidationError)
+ it_behaves_like 'returns errors', 'jobs:test:artifacts config should be a hash'
end
it 'populates a build options with complete artifacts configuration' do
@@ -1620,14 +1579,14 @@ module Gitlab
- my/test/something
YAML
- attributes = Gitlab::Ci::YamlProcessor.new(config).build_attributes('test')
+ attributes = Gitlab::Ci::YamlProcessor.new(config).execute.build_attributes('test')
expect(attributes.dig(*%i[options artifacts exclude])).to eq(%w[my/test/something])
end
end
describe "release" do
- let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
+ let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
let(:config) do
{
stages: %w[build test release],
@@ -1672,8 +1631,9 @@ module Gitlab
}
end
- let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
- let(:builds) { processor.stage_builds_attributes('deploy') }
+ subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
+
+ let(:builds) { subject.stage_builds_attributes('deploy') }
context 'when a production environment is specified' do
let(:environment) { 'production' }
@@ -1723,18 +1683,13 @@ module Gitlab
context 'is not a string' do
let(:environment) { 1 }
- it 'raises error' do
- expect { builds }.to raise_error(
- 'jobs:deploy_to_production:environment config should be a hash or a string')
- end
+ it_behaves_like 'returns errors', 'jobs:deploy_to_production:environment config should be a hash or a string'
end
context 'is not a valid string' do
let(:environment) { 'production:staging' }
- it 'raises error' do
- expect { builds }.to raise_error("jobs:deploy_to_production:environment name #{Gitlab::Regex.environment_name_regex_message}")
- end
+ it_behaves_like 'returns errors', "jobs:deploy_to_production:environment name #{Gitlab::Regex.environment_name_regex_message}"
end
context 'when on_stop is specified' do
@@ -1753,33 +1708,25 @@ module Gitlab
context 'without matching job' do
let(:close_review) { nil }
- it 'raises error' do
- expect { builds }.to raise_error('review job: on_stop job close_review is not defined')
- end
+ it_behaves_like 'returns errors', 'review job: on_stop job close_review is not defined'
end
context 'with close job without environment' do
let(:close_review) { { stage: 'deploy', script: 'test' } }
- it 'raises error' do
- expect { builds }.to raise_error('review job: on_stop job close_review does not have environment defined')
- end
+ it_behaves_like 'returns errors', 'review job: on_stop job close_review does not have environment defined'
end
context 'with close job for different environment' do
let(:close_review) { { stage: 'deploy', script: 'test', environment: 'production' } }
- it 'raises error' do
- expect { builds }.to raise_error('review job: on_stop job close_review have different environment name')
- end
+ it_behaves_like 'returns errors', 'review job: on_stop job close_review have different environment name'
end
context 'with close job without stop action' do
let(:close_review) { { stage: 'deploy', script: 'test', environment: { name: 'review' } } }
- it 'raises error' do
- expect { builds }.to raise_error('review job: on_stop job close_review needs to have action stop defined')
- end
+ it_behaves_like 'returns errors', 'review job: on_stop job close_review needs to have action stop defined'
end
end
end
@@ -1794,8 +1741,9 @@ module Gitlab
}
end
- let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
- let(:builds) { processor.stage_builds_attributes('deploy') }
+ subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
+
+ let(:builds) { subject.stage_builds_attributes('deploy') }
context 'when no timeout was provided' do
it 'does not include job_timeout' do
@@ -1809,9 +1757,7 @@ module Gitlab
config[:deploy_to_production][:timeout] = 'not-a-number'
end
- it 'raises an error for invalid number' do
- expect { builds }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:deploy_to_production:timeout config should be a duration')
- end
+ it_behaves_like 'returns errors', 'jobs:deploy_to_production:timeout config should be a duration'
end
context 'when some valid timeout was provided' do
@@ -1837,36 +1783,36 @@ module Gitlab
}
end
- subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
+ subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
context 'no dependencies' do
let(:dependencies) { }
- it { expect { subject }.not_to raise_error }
+ it { is_expected.to be_valid }
end
context 'dependencies to builds' do
let(:dependencies) { %w(build1 build2) }
- it { expect { subject }.not_to raise_error }
+ it { is_expected.to be_valid }
end
context 'dependencies to builds defined as symbols' do
let(:dependencies) { [:build1, :build2] }
- it { expect { subject }.not_to raise_error }
+ it { is_expected.to be_valid }
end
context 'undefined dependency' do
let(:dependencies) { ['undefined'] }
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'test1 job: undefined dependency: undefined') }
+ it_behaves_like 'returns errors', 'test1 job: undefined dependency: undefined'
end
context 'dependencies to deploy' do
let(:dependencies) { ['deploy'] }
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'test1 job: dependency deploy is not defined in prior stages') }
+ it_behaves_like 'returns errors', 'test1 job: dependency deploy is not defined in prior stages'
end
context 'when a job depends on another job that references a not-yet defined stage' do
@@ -1891,7 +1837,7 @@ module Gitlab
}
end
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, /is not defined in prior stages/) }
+ it_behaves_like 'returns errors', /is not defined in prior stages/
end
end
@@ -1910,10 +1856,10 @@ module Gitlab
}
end
- subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
+ subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
context 'no needs' do
- it { expect { subject }.not_to raise_error }
+ it { is_expected.to be_valid }
end
context 'needs two builds' do
@@ -2053,20 +1999,20 @@ module Gitlab
context 'undefined need' do
let(:needs) { ['undefined'] }
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'test1 job: undefined need: undefined') }
+ it_behaves_like 'returns errors', 'test1 job: undefined need: undefined'
end
context 'needs to deploy' do
let(:needs) { ['deploy'] }
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'test1 job: need deploy is not defined in prior stages') }
+ it_behaves_like 'returns errors', 'test1 job: need deploy is not defined in prior stages'
end
context 'needs and dependencies that are mismatching' do
let(:needs) { %w(build1) }
let(:dependencies) { %w(build2) }
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1 dependencies the build2 should be part of needs') }
+ it_behaves_like 'returns errors', 'jobs:test1 dependencies the build2 should be part of needs'
end
context 'needs with a Hash type and dependencies with a string type that are mismatching' do
@@ -2079,33 +2025,33 @@ module Gitlab
let(:dependencies) { %w(build3) }
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1 dependencies the build3 should be part of needs') }
+ it_behaves_like 'returns errors', 'jobs:test1 dependencies the build3 should be part of needs'
end
context 'needs with an array type and dependency with a string type' do
let(:needs) { %w(build1) }
let(:dependencies) { 'deploy' }
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1 dependencies should be an array of strings') }
+ it_behaves_like 'returns errors', 'jobs:test1 dependencies should be an array of strings'
end
context 'needs with a string type and dependency with an array type' do
let(:needs) { 'build1' }
let(:dependencies) { %w(deploy) }
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1:needs config can only be a hash or an array') }
+ it_behaves_like 'returns errors', 'jobs:test1:needs config can only be a hash or an array'
end
context 'needs with a Hash type and dependency with a string type' do
let(:needs) { { job: 'build1' } }
let(:dependencies) { 'deploy' }
- it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1 dependencies should be an array of strings') }
+ it_behaves_like 'returns errors', 'jobs:test1 dependencies should be an array of strings'
end
end
context 'with when/rules conflict' do
- subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
+ subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
let(:config) do
{
@@ -2121,9 +2067,7 @@ module Gitlab
}
end
- it 'raises no exceptions' do
- expect { subject }.not_to raise_error
- end
+ it { is_expected.to be_valid }
it 'returns all jobs regardless of their inclusion' do
expect(subject.builds.count).to eq(config.keys.count)
@@ -2141,9 +2085,7 @@ module Gitlab
}
end
- it 'raises a ValidationError' do
- expect { subject }.to raise_error(YamlProcessor::ValidationError, /may not be used with `rules`: when/)
- end
+ it_behaves_like 'returns errors', /may not be used with `rules`: when/
end
context 'used with job-level when:delayed' do
@@ -2159,14 +2101,12 @@ module Gitlab
}
end
- it 'raises a ValidationError' do
- expect { subject }.to raise_error(YamlProcessor::ValidationError, /may not be used with `rules`: when, start_in/)
- end
+ it_behaves_like 'returns errors', /may not be used with `rules`: when, start_in/
end
end
describe "Hidden jobs" do
- let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) }
+ let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config).execute }
subject { config_processor.stage_builds_attributes("test") }
@@ -2213,7 +2153,7 @@ module Gitlab
end
describe "YAML Alias/Anchor" do
- let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) }
+ let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config).execute }
subject { config_processor.stage_builds_attributes("build") }
@@ -2310,7 +2250,7 @@ module Gitlab
})
end
- it { expect { subject }.not_to raise_error }
+ it { is_expected.to be_valid }
end
context 'when job is not specified specified while artifact is' do
@@ -2323,11 +2263,7 @@ module Gitlab
})
end
- it do
- expect { subject }.to raise_error(
- described_class::ValidationError,
- /include config must specify the job where to fetch the artifact from/)
- end
+ it_behaves_like 'returns errors', /include config must specify the job where to fetch the artifact from/
end
context 'when include is a string' do
@@ -2343,376 +2279,323 @@ module Gitlab
})
end
- it { expect { subject }.not_to raise_error }
+ it { is_expected.to be_valid }
end
end
describe "Error handling" do
- it "fails to parse YAML" do
- expect do
- Gitlab::Ci::YamlProcessor.new("invalid: yaml: test")
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError)
+ subject { described_class.new(config).execute }
+
+ context 'when YAML syntax is invalid' do
+ let(:config) { 'invalid: yaml: test' }
+
+ it_behaves_like 'returns errors', /mapping values are not allowed/
end
- it "indicates that object is invalid" do
- expect do
- Gitlab::Ci::YamlProcessor.new("invalid_yaml")
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError)
+ context 'when object is invalid' do
+ let(:config) { 'invalid_yaml' }
+
+ it_behaves_like 'returns errors', /Invalid configuration format/
end
- it "returns errors if tags parameter is invalid" do
- config = YAML.dump({ rspec: { script: "test", tags: "mysql" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:tags config should be an array of strings")
+ context 'returns errors if tags parameter is invalid' do
+ let(:config) { YAML.dump({ rspec: { script: "test", tags: "mysql" } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:tags config should be an array of strings'
end
- it "returns errors if before_script parameter is invalid" do
- config = YAML.dump({ before_script: "bundle update", rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "before_script config should be an array containing strings and arrays of strings")
+ context 'returns errors if before_script parameter is invalid' do
+ let(:config) { YAML.dump({ before_script: "bundle update", rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'before_script config should be an array containing strings and arrays of strings'
end
- it "returns errors if job before_script parameter is not an array of strings" do
- config = YAML.dump({ rspec: { script: "test", before_script: [10, "test"] } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:before_script config should be an array containing strings and arrays of strings")
+ context 'returns errors if job before_script parameter is not an array of strings' do
+ let(:config) { YAML.dump({ rspec: { script: "test", before_script: [10, "test"] } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:before_script config should be an array containing strings and arrays of strings'
end
- it "returns errors if job before_script parameter is multi-level nested array of strings" do
- config = YAML.dump({ rspec: { script: "test", before_script: [["ls", ["pwd"]], "test"] } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:before_script config should be an array containing strings and arrays of strings")
+ context 'returns errors if job before_script parameter is multi-level nested array of strings' do
+ let(:config) { YAML.dump({ rspec: { script: "test", before_script: [["ls", ["pwd"]], "test"] } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:before_script config should be an array containing strings and arrays of strings'
end
- it "returns errors if after_script parameter is invalid" do
- config = YAML.dump({ after_script: "bundle update", rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "after_script config should be an array containing strings and arrays of strings")
+ context 'returns errors if after_script parameter is invalid' do
+ let(:config) { YAML.dump({ after_script: "bundle update", rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'after_script config should be an array containing strings and arrays of strings'
end
- it "returns errors if job after_script parameter is not an array of strings" do
- config = YAML.dump({ rspec: { script: "test", after_script: [10, "test"] } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:after_script config should be an array containing strings and arrays of strings")
+ context 'returns errors if job after_script parameter is not an array of strings' do
+ let(:config) { YAML.dump({ rspec: { script: "test", after_script: [10, "test"] } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:after_script config should be an array containing strings and arrays of strings'
end
- it "returns errors if job after_script parameter is multi-level nested array of strings" do
- config = YAML.dump({ rspec: { script: "test", after_script: [["ls", ["pwd"]], "test"] } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:after_script config should be an array containing strings and arrays of strings")
+ context 'returns errors if job after_script parameter is multi-level nested array of strings' do
+ let(:config) { YAML.dump({ rspec: { script: "test", after_script: [["ls", ["pwd"]], "test"] } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:after_script config should be an array containing strings and arrays of strings'
end
- it "returns errors if image parameter is invalid" do
- config = YAML.dump({ image: ["test"], rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "image config should be a hash or a string")
+ context 'returns errors if image parameter is invalid' do
+ let(:config) { YAML.dump({ image: ["test"], rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'image config should be a hash or a string'
end
- it "returns errors if job name is blank" do
- config = YAML.dump({ '' => { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:job name can't be blank")
+ context 'returns errors if job name is blank' do
+ let(:config) { YAML.dump({ '' => { script: "test" } }) }
+
+ it_behaves_like 'returns errors', "jobs:job name can't be blank"
end
- it "returns errors if job name is non-string" do
- config = YAML.dump({ 10 => { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:10 name should be a symbol")
+ context 'returns errors if job name is non-string' do
+ let(:config) { YAML.dump({ 10 => { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'jobs:10 name should be a symbol'
end
- it "returns errors if job image parameter is invalid" do
- config = YAML.dump({ rspec: { script: "test", image: ["test"] } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:image config should be a hash or a string")
+ context 'returns errors if job image parameter is invalid' do
+ let(:config) { YAML.dump({ rspec: { script: "test", image: ["test"] } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:image config should be a hash or a string'
end
- it "returns errors if services parameter is not an array" do
- config = YAML.dump({ services: "test", rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "services config should be a array")
+ context 'returns errors if services parameter is not an array' do
+ let(:config) { YAML.dump({ services: "test", rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'services config should be a array'
end
- it "returns errors if services parameter is not an array of strings" do
- config = YAML.dump({ services: [10, "test"], rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "services:service config should be a hash or a string")
+ context 'returns errors if services parameter is not an array of strings' do
+ let(:config) { YAML.dump({ services: [10, "test"], rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'services:service config should be a hash or a string'
end
- it "returns errors if job services parameter is not an array" do
- config = YAML.dump({ rspec: { script: "test", services: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:services config should be a array")
+ context 'returns errors if job services parameter is not an array' do
+ let(:config) { YAML.dump({ rspec: { script: "test", services: "test" } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:services config should be a array'
end
- it "returns errors if job services parameter is not an array of strings" do
- config = YAML.dump({ rspec: { script: "test", services: [10, "test"] } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:services:service config should be a hash or a string")
+ context 'returns errors if job services parameter is not an array of strings' do
+ let(:config) { YAML.dump({ rspec: { script: "test", services: [10, "test"] } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:services:service config should be a hash or a string'
end
- it "returns error if job configuration is invalid" do
- config = YAML.dump({ extra: "bundle update" })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "root config contains unknown keys: extra")
+ context 'returns error if job configuration is invalid' do
+ let(:config) { YAML.dump({ extra: "bundle update" }) }
+
+ it_behaves_like 'returns errors', 'jobs extra config should implement a script: or a trigger: keyword'
end
- it "returns errors if services configuration is not correct" do
- config = YAML.dump({ extra: { script: 'rspec', services: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:extra:services config should be a array")
+ context 'returns errors if services configuration is not correct' do
+ let(:config) { YAML.dump({ extra: { script: 'rspec', services: "test" } }) }
+
+ it_behaves_like 'returns errors', 'jobs:extra:services config should be a array'
end
- it "returns errors if there are no jobs defined" do
- config = YAML.dump({ before_script: ["bundle update"] })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs config should contain at least one visible job")
+ context 'returns errors if there are no jobs defined' do
+ let(:config) { YAML.dump({ before_script: ["bundle update"] }) }
+
+ it_behaves_like 'returns errors', 'jobs config should contain at least one visible job'
end
- it "returns errors if the job script is not defined" do
- config = YAML.dump({ rspec: { before_script: "test" } })
+ context 'returns errors if the job script is not defined' do
+ let(:config) { YAML.dump({ rspec: { before_script: "test" } }) }
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec script can't be blank")
+ it_behaves_like 'returns errors', 'jobs rspec config should implement a script: or a trigger: keyword'
end
- it "returns errors if there are no visible jobs defined" do
- config = YAML.dump({ before_script: ["bundle update"], '.hidden'.to_sym => { script: 'ls' } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs config should contain at least one visible job")
+ context 'returns errors if there are no visible jobs defined' do
+ let(:config) { YAML.dump({ before_script: ["bundle update"], '.hidden'.to_sym => { script: 'ls' } }) }
+
+ it_behaves_like 'returns errors', 'jobs config should contain at least one visible job'
end
- it "returns errors if job allow_failure parameter is not an boolean" do
- config = YAML.dump({ rspec: { script: "test", allow_failure: "string" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec allow failure should be a boolean value")
+ context 'returns errors if job allow_failure parameter is not an boolean' do
+ let(:config) { YAML.dump({ rspec: { script: "test", allow_failure: "string" } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec allow failure should be a boolean value'
end
- it "returns errors if job stage is not a string" do
- config = YAML.dump({ rspec: { script: "test", type: 1 } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:type config should be a string")
+ context 'returns errors if job stage is not a string' do
+ let(:config) { YAML.dump({ rspec: { script: "test", type: 1 } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:type config should be a string'
end
- it "returns errors if job stage is not a pre-defined stage" do
- config = YAML.dump({ rspec: { script: "test", type: "acceptance" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "rspec job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post")
+ context 'returns errors if job stage is not a pre-defined stage' do
+ let(:config) { YAML.dump({ rspec: { script: "test", type: "acceptance" } }) }
+
+ it_behaves_like 'returns errors', 'rspec job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post'
end
- it "returns errors if job stage is not a defined stage" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", type: "acceptance" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "rspec job: chosen stage does not exist; available stages are .pre, build, test, .post")
+ context 'returns errors if job stage is not a defined stage' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", type: "acceptance" } }) }
+
+ it_behaves_like 'returns errors', 'rspec job: chosen stage does not exist; available stages are .pre, build, test, .post'
end
- it "returns errors if stages is not an array" do
- config = YAML.dump({ stages: "test", rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "stages config should be an array of strings")
+ context 'returns errors if stages is not an array' do
+ let(:config) { YAML.dump({ stages: "test", rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'stages config should be an array of strings'
end
- it "returns errors if stages is not an array of strings" do
- config = YAML.dump({ stages: [true, "test"], rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "stages config should be an array of strings")
+ context 'returns errors if stages is not an array of strings' do
+ let(:config) { YAML.dump({ stages: [true, "test"], rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'stages config should be an array of strings'
end
- it "returns errors if variables is not a map" do
- config = YAML.dump({ variables: "test", rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "variables config should be a hash of key value pairs")
+ context 'returns errors if variables is not a map' do
+ let(:config) { YAML.dump({ variables: "test", rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs'
end
- it "returns errors if variables is not a map of key-value strings" do
- config = YAML.dump({ variables: { test: false }, rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "variables config should be a hash of key value pairs")
+ context 'returns errors if variables is not a map of key-value strings' do
+ let(:config) { YAML.dump({ variables: { test: false }, rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs'
end
- it "returns errors if job when is not on_success, on_failure or always" do
- config = YAML.dump({ rspec: { script: "test", when: 1 } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec when should be one of: #{Gitlab::Ci::Config::Entry::Job::ALLOWED_WHEN.join(', ')}")
+ context 'returns errors if job when is not on_success, on_failure or always' do
+ let(:config) { YAML.dump({ rspec: { script: "test", when: 1 } }) }
+
+ it_behaves_like 'returns errors', "jobs:rspec when should be one of: #{Gitlab::Ci::Config::Entry::Job::ALLOWED_WHEN.join(', ')}"
end
- it "returns errors if job artifacts:name is not an a string" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { name: 1 } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:artifacts name should be a string")
+ context 'returns errors if job artifacts:name is not an a string' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { name: 1 } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:artifacts name should be a string'
end
- it "returns errors if job artifacts:when is not an a predefined value" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { when: 1 } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:artifacts when should be on_success, on_failure or always")
+ context 'returns errors if job artifacts:when is not an a predefined value' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { when: 1 } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:artifacts when should be on_success, on_failure or always'
end
- it "returns errors if job artifacts:expire_in is not an a string" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { expire_in: 1 } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:artifacts expire in should be a duration")
+ context 'returns errors if job artifacts:expire_in is not an a string' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { expire_in: 1 } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:artifacts expire in should be a duration'
end
- it "returns errors if job artifacts:expire_in is not an a valid duration" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { expire_in: "7 elephants" } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:artifacts expire in should be a duration")
+ context 'returns errors if job artifacts:expire_in is not an a valid duration' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { expire_in: "7 elephants" } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:artifacts expire in should be a duration'
end
- it "returns errors if job artifacts:untracked is not an array of strings" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { untracked: "string" } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:artifacts untracked should be a boolean value")
+ context 'returns errors if job artifacts:untracked is not an array of strings' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { untracked: "string" } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:artifacts untracked should be a boolean value'
end
- it "returns errors if job artifacts:paths is not an array of strings" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { paths: "string" } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:artifacts paths should be an array of strings")
+ context 'returns errors if job artifacts:paths is not an array of strings' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { paths: "string" } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:artifacts paths should be an array of strings'
end
- it "returns errors if cache:untracked is not an array of strings" do
- config = YAML.dump({ cache: { untracked: "string" }, rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "cache:untracked config should be a boolean value")
+ context 'returns errors if cache:untracked is not an array of strings' do
+ let(:config) { YAML.dump({ cache: { untracked: "string" }, rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'cache:untracked config should be a boolean value'
end
- it "returns errors if cache:paths is not an array of strings" do
- config = YAML.dump({ cache: { paths: "string" }, rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "cache:paths config should be an array of strings")
+ context 'returns errors if cache:paths is not an array of strings' do
+ let(:config) { YAML.dump({ cache: { paths: "string" }, rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', 'cache:paths config should be an array of strings'
end
- it "returns errors if cache:key is not a string" do
- config = YAML.dump({ cache: { key: 1 }, rspec: { script: "test" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "cache:key should be a hash, a string or a symbol")
+ context 'returns errors if cache:key is not a string' do
+ let(:config) { YAML.dump({ cache: { key: 1 }, rspec: { script: "test" } }) }
+
+ it_behaves_like 'returns errors', "cache:key should be a hash, a string or a symbol"
end
- it "returns errors if job cache:key is not an a string" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: 1 } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:cache:key should be a hash, a string or a symbol")
+ context 'returns errors if job cache:key is not an a string' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: 1 } } }) }
+
+ it_behaves_like 'returns errors', "jobs:rspec:cache:key should be a hash, a string or a symbol"
end
- it 'returns errors if job cache:key:files is not an array of strings' do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [1] } } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key:files config should be an array of strings')
+ context 'returns errors if job cache:key:files is not an array of strings' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [1] } } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:cache:key:files config should be an array of strings'
end
- it 'returns errors if job cache:key:files is an empty array' do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [] } } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key:files config requires at least 1 item')
+ context 'returns errors if job cache:key:files is an empty array' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [] } } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:cache:key:files config requires at least 1 item'
end
- it 'returns errors if job defines only cache:key:prefix' do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 'prefix-key' } } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key config missing required keys: files')
+ context 'returns errors if job defines only cache:key:prefix' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 'prefix-key' } } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:cache:key config missing required keys: files'
end
- it 'returns errors if job cache:key:prefix is not an a string' do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 1, files: ['file'] } } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key:prefix config should be a string or symbol')
+ context 'returns errors if job cache:key:prefix is not an a string' do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 1, files: ['file'] } } } }) }
+
+ it_behaves_like 'returns errors', 'jobs:rspec:cache:key:prefix config should be a string or symbol'
end
- it "returns errors if job cache:untracked is not an array of strings" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { untracked: "string" } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:cache:untracked config should be a boolean value")
+ context "returns errors if job cache:untracked is not an array of strings" do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { untracked: "string" } } }) }
+
+ it_behaves_like 'returns errors', "jobs:rspec:cache:untracked config should be a boolean value"
end
- it "returns errors if job cache:paths is not an array of strings" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { paths: "string" } } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:cache:paths config should be an array of strings")
+ context "returns errors if job cache:paths is not an array of strings" do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { paths: "string" } } }) }
+
+ it_behaves_like 'returns errors', "jobs:rspec:cache:paths config should be an array of strings"
end
- it "returns errors if job dependencies is not an array of strings" do
- config = YAML.dump({ types: %w(build test), rspec: { script: "test", dependencies: "string" } })
- expect do
- Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec dependencies should be an array of strings")
+ context "returns errors if job dependencies is not an array of strings" do
+ let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", dependencies: "string" } }) }
+
+ it_behaves_like 'returns errors', "jobs:rspec dependencies should be an array of strings"
end
- it 'returns errors if pipeline variables expression policy is invalid' do
- config = YAML.dump({ rspec: { script: 'test', only: { variables: ['== null'] } } })
+ context 'returns errors if pipeline variables expression policy is invalid' do
+ let(:config) { YAML.dump({ rspec: { script: 'test', only: { variables: ['== null'] } } }) }
- expect { Gitlab::Ci::YamlProcessor.new(config) }
- .to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:only variables invalid expression syntax')
+ it_behaves_like 'returns errors', 'jobs:rspec:only variables invalid expression syntax'
end
- it 'returns errors if pipeline changes policy is invalid' do
- config = YAML.dump({ rspec: { script: 'test', only: { changes: [1] } } })
+ context 'returns errors if pipeline changes policy is invalid' do
+ let(:config) { YAML.dump({ rspec: { script: 'test', only: { changes: [1] } } }) }
- expect { Gitlab::Ci::YamlProcessor.new(config) }
- .to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:only changes should be an array of strings')
+ it_behaves_like 'returns errors', 'jobs:rspec:only changes should be an array of strings'
end
- it 'returns errors if extended hash configuration is invalid' do
- config = YAML.dump({ rspec: { extends: 'something', script: 'test' } })
+ context 'returns errors if extended hash configuration is invalid' do
+ let(:config) { YAML.dump({ rspec: { extends: 'something', script: 'test' } }) }
- expect { Gitlab::Ci::YamlProcessor.new(config) }
- .to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'rspec: unknown keys in `extends` (something)')
+ it_behaves_like 'returns errors', 'rspec: unknown keys in `extends` (something)'
end
- it 'returns errors if parallel is invalid' do
- config = YAML.dump({ rspec: { parallel: 'test', script: 'test' } })
+ context 'returns errors if parallel is invalid' do
+ let(:config) { YAML.dump({ rspec: { parallel: 'test', script: 'test' } }) }
- expect { Gitlab::Ci::YamlProcessor.new(config) }
- .to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:parallel should be an integer or a hash')
+ it_behaves_like 'returns errors', 'jobs:rspec:parallel should be an integer or a hash'
end
end
@@ -2750,8 +2633,8 @@ module Gitlab
end
end
- describe '.new_with_validation_errors' do
- subject { Gitlab::Ci::YamlProcessor.new_with_validation_errors(content) }
+ describe '#execute' do
+ subject { Gitlab::Ci::YamlProcessor.new(content).execute }
context 'when the YAML could not be parsed' do
let(:content) { YAML.dump('invalid: yaml: test') }
@@ -2759,7 +2642,6 @@ module Gitlab
it 'returns errors and empty configuration' do
expect(subject.valid?).to eq(false)
expect(subject.errors).to eq(['Invalid configuration format'])
- expect(subject.config).to be_blank
end
end
@@ -2769,7 +2651,6 @@ module Gitlab
it 'returns errors and empty configuration' do
expect(subject.valid?).to eq(false)
expect(subject.errors).to eq(['jobs:rspec:tags config should be an array of strings'])
- expect(subject.config).to be_blank
end
end
@@ -2781,7 +2662,6 @@ module Gitlab
expect(subject.errors).to contain_exactly(
'jobs:rspec config contains unknown keys: bad_tags',
'jobs:rspec rules should be an array of hashes')
- expect(subject.config).to be_blank
end
end
@@ -2791,7 +2671,6 @@ module Gitlab
it 'returns errors and empty configuration' do
expect(subject.valid?).to eq(false)
expect(subject.errors).to eq(['Please provide content of .gitlab-ci.yml'])
- expect(subject.config).to be_blank
end
end
@@ -2801,7 +2680,6 @@ module Gitlab
it 'returns errors and empty configuration' do
expect(subject.valid?).to eq(false)
expect(subject.errors).to eq(['Unknown alias: bad_alias'])
- expect(subject.config).to be_blank
end
end
@@ -2811,7 +2689,7 @@ module Gitlab
it 'returns errors and empty configuration' do
expect(subject.valid?).to eq(true)
expect(subject.errors).to be_empty
- expect(subject.config).to be_present
+ expect(subject.builds).to be_present
end
end
end
diff --git a/spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb b/spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb
index 47b2cf5dc4a..efdfc0a980b 100644
--- a/spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb
+++ b/spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb
@@ -3,12 +3,16 @@
require 'spec_helper'
RSpec.describe Gitlab::Cleanup::OrphanLfsFileReferences do
+ include ProjectForksHelper
+
let(:null_logger) { Logger.new('/dev/null') }
let(:project) { create(:project, :repository, lfs_enabled: true) }
let(:lfs_object) { create(:lfs_object) }
let!(:invalid_reference) { create(:lfs_objects_project, project: project, lfs_object: lfs_object) }
+ subject(:service) { described_class.new(project, logger: null_logger, dry_run: dry_run) }
+
before do
allow(null_logger).to receive(:info)
@@ -21,25 +25,66 @@ RSpec.describe Gitlab::Cleanup::OrphanLfsFileReferences do
end
context 'dry run' do
+ let(:dry_run) { true }
+
it 'prints messages and does not delete references' do
expect(null_logger).to receive(:info).with("[DRY RUN] Looking for orphan LFS files for project #{project.name_with_namespace}")
expect(null_logger).to receive(:info).with("[DRY RUN] Found invalid references: 1")
- expect { described_class.new(project, logger: null_logger).run! }
- .not_to change { project.lfs_objects.count }
+ expect { service.run! }.not_to change { project.lfs_objects.count }
end
end
context 'regular run' do
+ let(:dry_run) { false }
+
it 'prints messages and deletes invalid reference' do
expect(null_logger).to receive(:info).with("Looking for orphan LFS files for project #{project.name_with_namespace}")
expect(null_logger).to receive(:info).with("Removed invalid references: 1")
expect(ProjectCacheWorker).to receive(:perform_async).with(project.id, [], [:lfs_objects_size])
- expect { described_class.new(project, logger: null_logger, dry_run: false).run! }
- .to change { project.lfs_objects.count }.from(2).to(1)
+ expect { service.run! }.to change { project.lfs_objects.count }.from(2).to(1)
expect(LfsObjectsProject.exists?(invalid_reference.id)).to be_falsey
end
+
+ context 'LFS object is in design repository' do
+ before do
+ expect(project.design_repository).to receive(:exists?).and_return(true)
+
+ stub_lfs_pointers(project.design_repository, lfs_object.oid)
+ end
+
+ it 'is not removed' do
+ expect { service.run! }.not_to change { project.lfs_objects.count }
+ end
+ end
+
+ context 'LFS object is in wiki repository' do
+ before do
+ expect(project.wiki.repository).to receive(:exists?).and_return(true)
+
+ stub_lfs_pointers(project.wiki.repository, lfs_object.oid)
+ end
+
+ it 'is not removed' do
+ expect { service.run! }.not_to change { project.lfs_objects.count }
+ end
+ end
+ end
+
+ context 'LFS for project snippets' do
+ let(:snippet) { create(:project_snippet) }
+
+ it 'is disabled' do
+ # Support project snippets here before enabling LFS for them
+ expect(snippet.repository.lfs_enabled?).to be_falsy
+ end
+ end
+
+ def stub_lfs_pointers(repo, *oids)
+ expect(repo.gitaly_blob_client)
+ .to receive(:get_all_lfs_pointers)
+ .and_return(oids.map { |oid| OpenStruct.new(lfs_oid: oid) })
end
end
diff --git a/spec/lib/gitlab/conan_token_spec.rb b/spec/lib/gitlab/conan_token_spec.rb
index b17f2eaa8d8..be1d3e757f5 100644
--- a/spec/lib/gitlab/conan_token_spec.rb
+++ b/spec/lib/gitlab/conan_token_spec.rb
@@ -85,7 +85,7 @@ RSpec.describe Gitlab::ConanToken do
it 'returns the encoded JWT' do
allow(SecureRandom).to receive(:uuid).and_return('u-u-i-d')
- Timecop.freeze do
+ freeze_time do
jwt = build_jwt(access_token_id: 123, user_id: 456)
token = described_class.new(access_token_id: 123, user_id: 456)
diff --git a/spec/lib/gitlab/consul/internal_spec.rb b/spec/lib/gitlab/consul/internal_spec.rb
new file mode 100644
index 00000000000..5889dd8b41d
--- /dev/null
+++ b/spec/lib/gitlab/consul/internal_spec.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Consul::Internal do
+ let(:api_url) { 'http://127.0.0.1:8500' }
+
+ let(:consul_settings) do
+ {
+ api_url: api_url
+ }
+ end
+
+ before do
+ stub_config(consul: consul_settings)
+ end
+
+ describe '.api_url' do
+ it 'returns correct value' do
+ expect(described_class.api_url).to eq(api_url)
+ end
+
+ context 'when consul setting is not present in gitlab.yml' do
+ before do
+ allow(Gitlab.config).to receive(:consul).and_raise(Settingslogic::MissingSetting)
+ end
+
+ it 'does not fail' do
+ expect(described_class.api_url).to be_nil
+ end
+ end
+ end
+
+ shared_examples 'handles failure response' do
+ it 'raises Gitlab::Consul::Internal::SocketError when SocketError is rescued' do
+ stub_consul_discover_prometheus.to_raise(::SocketError)
+
+ expect { subject }
+ .to raise_error(described_class::SocketError)
+ end
+
+ it 'raises Gitlab::Consul::Internal::SSLError when OpenSSL::SSL::SSLError is rescued' do
+ stub_consul_discover_prometheus.to_raise(OpenSSL::SSL::SSLError)
+
+ expect { subject }
+ .to raise_error(described_class::SSLError)
+ end
+
+ it 'raises Gitlab::Consul::Internal::ECONNREFUSED when Errno::ECONNREFUSED is rescued' do
+ stub_consul_discover_prometheus.to_raise(Errno::ECONNREFUSED)
+
+ expect { subject }
+ .to raise_error(described_class::ECONNREFUSED)
+ end
+
+ it 'raises Consul::Internal::UnexpectedResponseError when StandardError is rescued' do
+ stub_consul_discover_prometheus.to_raise(StandardError)
+
+ expect { subject }
+ .to raise_error(described_class::UnexpectedResponseError)
+ end
+
+ it 'raises Consul::Internal::UnexpectedResponseError when request returns 500' do
+ stub_consul_discover_prometheus.to_return(status: 500, body: '{ message: "FAIL!" }')
+
+ expect { subject }
+ .to raise_error(described_class::UnexpectedResponseError)
+ end
+
+ it 'raises Consul::Internal::UnexpectedResponseError when request returns non json data' do
+ stub_consul_discover_prometheus.to_return(status: 200, body: 'not json')
+
+ expect { subject }
+ .to raise_error(described_class::UnexpectedResponseError)
+ end
+ end
+
+ shared_examples 'returns nil given blank value of' do |input_symbol|
+ [nil, ''].each do |value|
+ let(input_symbol) { value }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '.discover_service' do
+ subject { described_class.discover_service(service_name: service_name) }
+
+ let(:service_name) { 'prometheus' }
+
+ it_behaves_like 'returns nil given blank value of', :api_url
+
+ it_behaves_like 'returns nil given blank value of', :service_name
+
+ context 'one service discovered' do
+ before do
+ stub_consul_discover_prometheus.to_return(status: 200, body: '[{"ServiceAddress":"prom.net","ServicePort":9090}]')
+ end
+
+ it 'returns the service address and port' do
+ is_expected.to eq(["prom.net", 9090])
+ end
+ end
+
+ context 'multiple services discovered' do
+ before do
+ stub_consul_discover_prometheus
+ .to_return(status: 200, body: '[{"ServiceAddress":"prom_1.net","ServicePort":9090},{"ServiceAddress":"prom.net","ServicePort":9090}]')
+ end
+
+ it 'uses the first service' do
+ is_expected.to eq(["prom_1.net", 9090])
+ end
+ end
+
+ it_behaves_like 'handles failure response'
+ end
+
+ describe '.discover_prometheus_server_address' do
+ subject { described_class.discover_prometheus_server_address }
+
+ before do
+ stub_consul_discover_prometheus
+ .to_return(status: 200, body: '[{"ServiceAddress":"prom.net","ServicePort":9090}]')
+ end
+
+ it 'returns the server address' do
+ is_expected.to eq('prom.net:9090')
+ end
+
+ it_behaves_like 'returns nil given blank value of', :api_url
+
+ it_behaves_like 'handles failure response'
+ end
+
+ def stub_consul_discover_prometheus
+ stub_request(:get, /v1\/catalog\/service\/prometheus/)
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb
index afab19de2ab..17104715580 100644
--- a/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe Gitlab::CycleAnalytics::CodeStage do
describe '#project_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
@@ -76,7 +76,7 @@ RSpec.describe Gitlab::CycleAnalytics::CodeStage do
describe '#group_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb
index 246003cde84..e0a8e2c17a3 100644
--- a/spec/lib/gitlab/cycle_analytics/events_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb
@@ -306,48 +306,6 @@ RSpec.describe 'cycle analytics events' do
end
end
- describe '#production_events', :sidekiq_might_not_need_inline do
- let(:stage) { :production }
- let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
-
- before do
- merge_merge_requests_closing_issue(user, project, context)
- deploy_master(user, project)
- end
-
- it 'has the total time' do
- expect(events.first[:total_time]).not_to be_empty
- end
-
- it 'has a title' do
- expect(events.first[:title]).to eq(context.title)
- end
-
- it 'has the URL' do
- expect(events.first[:url]).not_to be_nil
- end
-
- it 'has an iid' do
- expect(events.first[:iid]).to eq(context.iid.to_s)
- end
-
- it 'has a created_at timestamp' do
- expect(events.first[:created_at]).to end_with('ago')
- end
-
- it "has the author's URL" do
- expect(events.first[:author][:web_url]).not_to be_nil
- end
-
- it "has the author's avatar URL" do
- expect(events.first[:author][:avatar_url]).not_to be_nil
- end
-
- it "has the author's name" do
- expect(events.first[:author][:name]).to eq(context.author.name)
- end
- end
-
def setup(context)
milestone = create(:milestone, project: project)
context.update(milestone: milestone)
diff --git a/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb
index 9ec71e6ed72..c7ab2b9b84b 100644
--- a/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe Gitlab::CycleAnalytics::IssueStage do
describe '#median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
@@ -65,7 +65,7 @@ RSpec.describe Gitlab::CycleAnalytics::IssueStage do
describe '#group_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
@@ -87,7 +87,7 @@ RSpec.describe Gitlab::CycleAnalytics::IssueStage do
describe '#group_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
diff --git a/spec/lib/gitlab/cycle_analytics/permissions_spec.rb b/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
index 3fd48993e5f..7650ff3cace 100644
--- a/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
@@ -21,10 +21,6 @@ RSpec.describe Gitlab::CycleAnalytics::Permissions do
expect(subject[:staging]).to eq(false)
end
- it 'has no permissions to production stage' do
- expect(subject[:production]).to eq(false)
- end
-
it 'has no permissions to code stage' do
expect(subject[:code]).to eq(false)
end
@@ -55,10 +51,6 @@ RSpec.describe Gitlab::CycleAnalytics::Permissions do
expect(subject[:staging]).to eq(true)
end
- it 'has permissions to production stage' do
- expect(subject[:production]).to eq(true)
- end
-
it 'has permissions to code stage' do
expect(subject[:code]).to eq(true)
end
@@ -121,9 +113,5 @@ RSpec.describe Gitlab::CycleAnalytics::Permissions do
it 'has no permissions to issue stage' do
expect(subject[:issue]).to eq(false)
end
-
- it 'has no permissions to production stage' do
- expect(subject[:production]).to eq(false)
- end
end
end
diff --git a/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb
index 66d00edacb7..2547c05c025 100644
--- a/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe Gitlab::CycleAnalytics::PlanStage do
describe '#project_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
@@ -67,7 +67,7 @@ RSpec.describe Gitlab::CycleAnalytics::PlanStage do
describe '#group_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
diff --git a/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb
deleted file mode 100644
index 73b17194f72..00000000000
--- a/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::CycleAnalytics::ProductionStage do
- let(:stage_name) { 'Total' }
-
- it_behaves_like 'base stage'
-end
diff --git a/spec/lib/gitlab/cycle_analytics/review_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/review_stage_spec.rb
index cdd1cca6837..5593013740e 100644
--- a/spec/lib/gitlab/cycle_analytics/review_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/review_stage_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe Gitlab::CycleAnalytics::ReviewStage do
describe '#project_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
@@ -70,7 +70,7 @@ RSpec.describe Gitlab::CycleAnalytics::ReviewStage do
describe '#group_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
diff --git a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
index 9ece24074e7..719d4a69985 100644
--- a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
@@ -231,7 +231,7 @@ RSpec.describe Gitlab::CycleAnalytics::StageSummary do
context 'when `from` and `to` are within a day' do
it 'returns the number of deployments made on that day' do
- Timecop.freeze(Time.now) do
+ freeze_time do
create(:deployment, :success, project: project)
options[:from] = options[:to] = Time.now
diff --git a/spec/lib/gitlab/cycle_analytics/staging_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/staging_stage_spec.rb
index 69e42adb139..852f7041dc6 100644
--- a/spec/lib/gitlab/cycle_analytics/staging_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/staging_stage_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe Gitlab::CycleAnalytics::StagingStage do
describe '#project_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
@@ -79,7 +79,7 @@ RSpec.describe Gitlab::CycleAnalytics::StagingStage do
describe '#group_median' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
diff --git a/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb
index 9a207d32167..49ee6624260 100644
--- a/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe Gitlab::CycleAnalytics::TestStage do
end
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'counts median from issues with metrics' do
diff --git a/spec/lib/gitlab/danger/changelog_spec.rb b/spec/lib/gitlab/danger/changelog_spec.rb
index 3c67e9ca8ea..2da60f4f8bd 100644
--- a/spec/lib/gitlab/danger/changelog_spec.rb
+++ b/spec/lib/gitlab/danger/changelog_spec.rb
@@ -16,20 +16,47 @@ RSpec.describe Gitlab::Danger::Changelog do
let(:fake_gitlab) { double('fake-gitlab', mr_labels: mr_labels, mr_json: mr_json) }
let(:changes_by_category) { nil }
+ let(:sanitize_mr_title) { nil }
let(:ee?) { false }
- let(:fake_helper) { double('fake-helper', changes_by_category: changes_by_category, ee?: ee?) }
+ let(:fake_helper) { double('fake-helper', changes_by_category: changes_by_category, sanitize_mr_title: sanitize_mr_title, ee?: ee?) }
let(:fake_danger) { new_fake_danger.include(described_class) }
subject(:changelog) { fake_danger.new(git: fake_git, gitlab: fake_gitlab, helper: fake_helper) }
- describe '#needed?' do
+ describe '#required?' do
+ subject { changelog.required? }
+
+ context 'added files contain a migration' do
+ [
+ 'db/migrate/20200000000000_new_migration.rb',
+ 'db/post_migrate/20200000000000_new_migration.rb'
+ ].each do |file_path|
+ let(:added_files) { [file_path] }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ context 'added files do not contain a migration' do
+ [
+ 'app/models/model.rb',
+ 'app/assets/javascripts/file.js'
+ ].each do |file_path|
+ let(:added_files) { [file_path] }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+ end
+
+ describe '#optional?' do
let(:category_with_changelog) { :backend }
let(:label_with_changelog) { 'frontend' }
let(:category_without_changelog) { Gitlab::Danger::Changelog::NO_CHANGELOG_CATEGORIES.first }
let(:label_without_changelog) { Gitlab::Danger::Changelog::NO_CHANGELOG_LABELS.first }
- subject { changelog.needed? }
+ subject { changelog.optional? }
context 'when MR contains only categories requiring no changelog' do
let(:changes_by_category) { { category_without_changelog => nil } }
@@ -121,4 +148,43 @@ RSpec.describe Gitlab::Danger::Changelog do
it { is_expected.to be_falsy }
end
end
+
+ describe '#modified_text' do
+ let(:sanitize_mr_title) { 'Fake Title' }
+ let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } }
+
+ subject { changelog.modified_text }
+
+ it do
+ expect(subject).to include('CHANGELOG.md was edited')
+ expect(subject).to include('bin/changelog -m 1234 "Fake Title"')
+ expect(subject).to include('bin/changelog --ee -m 1234 "Fake Title"')
+ end
+ end
+
+ describe '#required_text' do
+ let(:sanitize_mr_title) { 'Fake Title' }
+ let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } }
+
+ subject { changelog.required_text }
+
+ it do
+ expect(subject).to include('CHANGELOG missing')
+ expect(subject).to include('bin/changelog -m 1234 "Fake Title"')
+ expect(subject).not_to include('--ee')
+ end
+ end
+
+ describe 'optional_text' do
+ let(:sanitize_mr_title) { 'Fake Title' }
+ let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } }
+
+ subject { changelog.optional_text }
+
+ it do
+ expect(subject).to include('CHANGELOG missing')
+ expect(subject).to include('bin/changelog -m 1234 "Fake Title"')
+ expect(subject).to include('bin/changelog --ee -m 1234 "Fake Title"')
+ end
+ end
end
diff --git a/spec/lib/gitlab/danger/helper_spec.rb b/spec/lib/gitlab/danger/helper_spec.rb
index e5018e46634..c7d55c396ef 100644
--- a/spec/lib/gitlab/danger/helper_spec.rb
+++ b/spec/lib/gitlab/danger/helper_spec.rb
@@ -76,6 +76,30 @@ RSpec.describe Gitlab::Danger::Helper do
end
end
+ describe '#changed_lines' do
+ subject { helper.changed_lines('changed_file.rb') }
+
+ before do
+ allow(fake_git).to receive(:diff_for_file).with('changed_file.rb').and_return(diff)
+ end
+
+ context 'when file has diff' do
+ let(:diff) { double(:diff, patch: "+ # New change here\n+ # New change there") }
+
+ it 'returns file changes' do
+ is_expected.to eq(['+ # New change here', '+ # New change there'])
+ end
+ end
+
+ context 'when file has no diff (renamed without changes)' do
+ let(:diff) { nil }
+
+ it 'returns a blank array' do
+ is_expected.to eq([])
+ end
+ end
+ end
+
describe "changed_files" do
it 'returns list of changed files matching given regex' do
expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb usage_data.rb])
@@ -371,22 +395,6 @@ RSpec.describe Gitlab::Danger::Helper do
end
end
- describe '#missing_database_labels' do
- subject { helper.missing_database_labels(current_mr_labels) }
-
- context 'when current merge request has ~database::review pending' do
- let(:current_mr_labels) { ['database::review pending', 'feature'] }
-
- it { is_expected.to match_array(['database']) }
- end
-
- context 'when current merge request does not have ~database::review pending' do
- let(:current_mr_labels) { ['feature'] }
-
- it { is_expected.to match_array(['database', 'database::review pending']) }
- end
- end
-
describe '#sanitize_mr_title' do
where(:mr_title, :expected_mr_title) do
'My MR title' | 'My MR title'
diff --git a/spec/lib/gitlab/danger/teammate_spec.rb b/spec/lib/gitlab/danger/teammate_spec.rb
index 12819614fab..6fd32493d6b 100644
--- a/spec/lib/gitlab/danger/teammate_spec.rb
+++ b/spec/lib/gitlab/danger/teammate_spec.rb
@@ -170,47 +170,38 @@ RSpec.describe Gitlab::Danger::Teammate do
end
describe '#markdown_name' do
- context 'when timezone_experiment == false' do
- it 'returns markdown name as-is' do
- expect(subject.markdown_name).to eq(options['markdown_name'])
- expect(subject.markdown_name(timezone_experiment: false)).to eq(options['markdown_name'])
- end
+ it 'returns markdown name with timezone info' do
+ expect(subject.markdown_name).to eq("#{options['markdown_name']} (UTC+2)")
end
- context 'when timezone_experiment == true' do
- it 'returns markdown name with timezone info' do
- expect(subject.markdown_name(timezone_experiment: true)).to eq("#{options['markdown_name']} (UTC+2)")
- end
-
- context 'when offset is 1.5' do
- let(:tz_offset_hours) { 1.5 }
+ context 'when offset is 1.5' do
+ let(:tz_offset_hours) { 1.5 }
- it 'returns markdown name with timezone info, not truncated' do
- expect(subject.markdown_name(timezone_experiment: true)).to eq("#{options['markdown_name']} (UTC+1.5)")
- end
+ it 'returns markdown name with timezone info, not truncated' do
+ expect(subject.markdown_name).to eq("#{options['markdown_name']} (UTC+1.5)")
end
+ end
- context 'when author is given' do
- where(:tz_offset_hours, :author_offset, :diff_text) do
- -12 | -10 | "2 hours behind `@mario`"
- -10 | -12 | "2 hours ahead of `@mario`"
- -10 | 2 | "12 hours behind `@mario`"
- 2 | 4 | "2 hours behind `@mario`"
- 4 | 2 | "2 hours ahead of `@mario`"
- 2 | 3 | "1 hour behind `@mario`"
- 3 | 2 | "1 hour ahead of `@mario`"
- 2 | 2 | "same timezone as `@mario`"
- end
+ context 'when author is given' do
+ where(:tz_offset_hours, :author_offset, :diff_text) do
+ -12 | -10 | "2 hours behind `@mario`"
+ -10 | -12 | "2 hours ahead of `@mario`"
+ -10 | 2 | "12 hours behind `@mario`"
+ 2 | 4 | "2 hours behind `@mario`"
+ 4 | 2 | "2 hours ahead of `@mario`"
+ 2 | 3 | "1 hour behind `@mario`"
+ 3 | 2 | "1 hour ahead of `@mario`"
+ 2 | 2 | "same timezone as `@mario`"
+ end
- with_them do
- it 'returns markdown name with timezone info' do
- author = described_class.new(options.merge('username' => 'mario', 'tz_offset_hours' => author_offset))
+ with_them do
+ it 'returns markdown name with timezone info' do
+ author = described_class.new(options.merge('username' => 'mario', 'tz_offset_hours' => author_offset))
- floored_offset_hours = subject.__send__(:floored_offset_hours)
- utc_offset = floored_offset_hours >= 0 ? "+#{floored_offset_hours}" : floored_offset_hours
+ floored_offset_hours = subject.__send__(:floored_offset_hours)
+ utc_offset = floored_offset_hours >= 0 ? "+#{floored_offset_hours}" : floored_offset_hours
- expect(subject.markdown_name(timezone_experiment: true, author: author)).to eq("#{options['markdown_name']} (UTC#{utc_offset}, #{diff_text})")
- end
+ expect(subject.markdown_name(author: author)).to eq("#{options['markdown_name']} (UTC#{utc_offset}, #{diff_text})")
end
end
end
diff --git a/spec/lib/gitlab/data_builder/deployment_spec.rb b/spec/lib/gitlab/data_builder/deployment_spec.rb
index 57bde6262a9..155e66e2fcd 100644
--- a/spec/lib/gitlab/data_builder/deployment_spec.rb
+++ b/spec/lib/gitlab/data_builder/deployment_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Gitlab::DataBuilder::Deployment do
describe '.build' do
it 'returns the object kind for a deployment' do
- deployment = build(:deployment)
+ deployment = build(:deployment, deployable: nil, environment: create(:environment))
data = described_class.build(deployment)
diff --git a/spec/lib/gitlab/database/background_migration_job_spec.rb b/spec/lib/gitlab/database/background_migration_job_spec.rb
index 40f47325be3..dd5bf8b512f 100644
--- a/spec/lib/gitlab/database/background_migration_job_spec.rb
+++ b/spec/lib/gitlab/database/background_migration_job_spec.rb
@@ -71,6 +71,15 @@ RSpec.describe Gitlab::Database::BackgroundMigrationJob do
expect(job4.reload).to be_pending
end
+ it 'returns the number of jobs updated' do
+ expect(described_class.succeeded.count).to eq(0)
+
+ jobs_updated = described_class.mark_all_as_succeeded('::TestJob', [1, 100])
+
+ expect(jobs_updated).to eq(2)
+ expect(described_class.succeeded.count).to eq(2)
+ end
+
context 'when previous matching jobs have already succeeded' do
let(:initial_time) { Time.now.round }
let!(:job1) { create(:background_migration_job, :succeeded, created_at: initial_time, updated_at: initial_time) }
diff --git a/spec/lib/gitlab/database/batch_count_spec.rb b/spec/lib/gitlab/database/batch_count_spec.rb
index 1f84a915cdc..71d3666602f 100644
--- a/spec/lib/gitlab/database/batch_count_spec.rb
+++ b/spec/lib/gitlab/database/batch_count_spec.rb
@@ -9,12 +9,16 @@ RSpec.describe Gitlab::Database::BatchCount do
let(:column) { :author_id }
let(:in_transaction) { false }
- let(:user) { create(:user) }
- let(:another_user) { create(:user) }
- before do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:another_user) { create(:user) }
+
+ before_all do
create_list(:issue, 3, author: user)
create_list(:issue, 2, author: another_user)
+ end
+
+ before do
allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(in_transaction)
end
diff --git a/spec/lib/gitlab/database/concurrent_reindex_spec.rb b/spec/lib/gitlab/database/concurrent_reindex_spec.rb
new file mode 100644
index 00000000000..4e2c3f547d4
--- /dev/null
+++ b/spec/lib/gitlab/database/concurrent_reindex_spec.rb
@@ -0,0 +1,207 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::ConcurrentReindex, '#execute' do
+ subject { described_class.new(index_name, logger: logger) }
+
+ let(:table_name) { '_test_reindex_table' }
+ let(:column_name) { '_test_column' }
+ let(:index_name) { '_test_reindex_index' }
+ let(:logger) { double('logger', debug: nil, info: nil, error: nil ) }
+ let(:connection) { ActiveRecord::Base.connection }
+
+ before do
+ connection.execute(<<~SQL)
+ CREATE TABLE #{table_name} (
+ id serial NOT NULL PRIMARY KEY,
+ #{column_name} integer NOT NULL);
+
+ CREATE INDEX #{index_name} ON #{table_name} (#{column_name});
+ SQL
+ end
+
+ context 'when the index does not exist' do
+ before do
+ connection.execute(<<~SQL)
+ DROP INDEX #{index_name}
+ SQL
+ end
+
+ it 'raises an error' do
+ expect { subject.execute }.to raise_error(described_class::ReindexError, /does not exist/)
+ end
+ end
+
+ context 'when the index is unique' do
+ before do
+ connection.execute(<<~SQL)
+ DROP INDEX #{index_name};
+ CREATE UNIQUE INDEX #{index_name} ON #{table_name} (#{column_name})
+ SQL
+ end
+
+ it 'raises an error' do
+ expect do
+ subject.execute
+ end.to raise_error(described_class::ReindexError, /UNIQUE indexes are currently not supported/)
+ end
+ end
+
+ context 'replacing the original index with a rebuilt copy' do
+ let(:replacement_name) { 'tmp_reindex__test_reindex_index' }
+ let(:replaced_name) { 'old_reindex__test_reindex_index' }
+
+ let(:create_index) { "CREATE INDEX CONCURRENTLY #{replacement_name} ON public.#{table_name} USING btree (#{column_name})" }
+ let(:drop_index) { "DROP INDEX CONCURRENTLY IF EXISTS #{replacement_name}" }
+
+ let!(:original_index) { find_index_create_statement }
+
+ before do
+ allow(subject).to receive(:connection).and_return(connection)
+ allow(subject).to receive(:disable_statement_timeout).and_yield
+ end
+
+ it 'replaces the existing index with an identical index' do
+ expect(subject).to receive(:disable_statement_timeout).exactly(3).times.and_yield
+
+ expect_to_execute_concurrently_in_order(drop_index)
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
+ expect(instance).to receive(:run).with(raise_on_exhaustion: true).and_yield
+ end
+
+ expect_to_execute_in_order("ALTER INDEX #{index_name} RENAME TO #{replaced_name}")
+ expect_to_execute_in_order("ALTER INDEX #{replacement_name} RENAME TO #{index_name}")
+ expect_to_execute_in_order("ALTER INDEX #{replaced_name} RENAME TO #{replacement_name}")
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ subject.execute
+
+ check_index_exists
+ end
+
+ context 'when a dangling index is left from a previous run' do
+ before do
+ connection.execute("CREATE INDEX #{replacement_name} ON #{table_name} (#{column_name})")
+ end
+
+ it 'replaces the existing index with an identical index' do
+ expect(subject).to receive(:disable_statement_timeout).exactly(3).times.and_yield
+
+ expect_to_execute_concurrently_in_order(drop_index)
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
+ expect(instance).to receive(:run).with(raise_on_exhaustion: true).and_yield
+ end
+
+ expect_to_execute_in_order("ALTER INDEX #{index_name} RENAME TO #{replaced_name}")
+ expect_to_execute_in_order("ALTER INDEX #{replacement_name} RENAME TO #{index_name}")
+ expect_to_execute_in_order("ALTER INDEX #{replaced_name} RENAME TO #{replacement_name}")
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ subject.execute
+
+ check_index_exists
+ end
+ end
+
+ context 'when it fails to create the replacement index' do
+ it 'safely cleans up and signals the error' do
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ expect(connection).to receive(:execute).with(create_index).ordered
+ .and_raise(ActiveRecord::ConnectionTimeoutError, 'connect timeout')
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ expect { subject.execute }.to raise_error(described_class::ReindexError, /connect timeout/)
+
+ check_index_exists
+ end
+ end
+
+ context 'when the replacement index is not valid' do
+ it 'safely cleans up and signals the error' do
+ expect_to_execute_concurrently_in_order(drop_index)
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect(subject).to receive(:replacement_index_valid?).and_return(false)
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ expect { subject.execute }.to raise_error(described_class::ReindexError, /replacement index was created as INVALID/)
+
+ check_index_exists
+ end
+ end
+
+ context 'when a database error occurs while swapping the indexes' do
+ it 'safely cleans up and signals the error' do
+ expect_to_execute_concurrently_in_order(drop_index)
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
+ expect(instance).to receive(:run).with(raise_on_exhaustion: true).and_yield
+ end
+
+ expect(connection).to receive(:execute).ordered
+ .with("ALTER INDEX #{index_name} RENAME TO #{replaced_name}")
+ .and_raise(ActiveRecord::ConnectionTimeoutError, 'connect timeout')
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ expect { subject.execute }.to raise_error(described_class::ReindexError, /connect timeout/)
+
+ check_index_exists
+ end
+ end
+
+ context 'when with_lock_retries fails to acquire the lock' do
+ it 'safely cleans up and signals the error' do
+ expect_to_execute_concurrently_in_order(drop_index)
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
+ expect(instance).to receive(:run).with(raise_on_exhaustion: true)
+ .and_raise(::Gitlab::Database::WithLockRetries::AttemptsExhaustedError, 'exhausted')
+ end
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ expect { subject.execute }.to raise_error(described_class::ReindexError, /exhausted/)
+
+ check_index_exists
+ end
+ end
+ end
+
+ def expect_to_execute_concurrently_in_order(sql)
+ # Indexes cannot be created CONCURRENTLY in a transaction. Since the tests are wrapped in transactions,
+ # verify the original call but pass through the non-concurrent form.
+ expect(connection).to receive(:execute).with(sql).ordered.and_wrap_original do |method, sql|
+ method.call(sql.sub(/CONCURRENTLY/, ''))
+ end
+ end
+
+ def expect_to_execute_in_order(sql)
+ expect(connection).to receive(:execute).with(sql).ordered.and_call_original
+ end
+
+ def find_index_create_statement
+ ActiveRecord::Base.connection.select_value(<<~SQL)
+ SELECT indexdef
+ FROM pg_indexes
+ WHERE schemaname = 'public'
+ AND indexname = #{ActiveRecord::Base.connection.quote(index_name)}
+ SQL
+ end
+
+ def check_index_exists
+ expect(find_index_create_statement).to eq(original_index)
+ end
+end
diff --git a/spec/lib/gitlab/database/custom_structure_spec.rb b/spec/lib/gitlab/database/custom_structure_spec.rb
index b3bdca0acdd..04ce1e4ad9a 100644
--- a/spec/lib/gitlab/database/custom_structure_spec.rb
+++ b/spec/lib/gitlab/database/custom_structure_spec.rb
@@ -9,7 +9,6 @@ RSpec.describe Gitlab::Database::CustomStructure do
<<~DATA
-- this file tracks custom GitLab data, such as foreign keys referencing partitioned tables
-- more details can be found in the issue: https://gitlab.com/gitlab-org/gitlab/-/issues/201872
- SET search_path=public;
DATA
end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 7d26fbb1132..0bdcca630aa 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -903,15 +903,22 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
describe '#change_column_type_concurrently' do
it 'changes the column type' do
expect(model).to receive(:rename_column_concurrently)
- .with('users', 'username', 'username_for_type_change', type: :text, type_cast_function: nil)
+ .with('users', 'username', 'username_for_type_change', type: :text, type_cast_function: nil, batch_column_name: :id)
model.change_column_type_concurrently('users', 'username', :text)
end
+ it 'passed the batch column name' do
+ expect(model).to receive(:rename_column_concurrently)
+ .with('users', 'username', 'username_for_type_change', type: :text, type_cast_function: nil, batch_column_name: :user_id)
+
+ model.change_column_type_concurrently('users', 'username', :text, batch_column_name: :user_id)
+ end
+
context 'with type cast' do
it 'changes the column type with casting the value to the new type' do
expect(model).to receive(:rename_column_concurrently)
- .with('users', 'username', 'username_for_type_change', type: :text, type_cast_function: 'JSON')
+ .with('users', 'username', 'username_for_type_change', type: :text, type_cast_function: 'JSON', batch_column_name: :id)
model.change_column_type_concurrently('users', 'username', :text, type_cast_function: 'JSON')
end
diff --git a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb
index 042ac498373..48132d68031 100644
--- a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb
@@ -86,7 +86,7 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
let!(:id3) { create(:user).id }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
before do
diff --git a/spec/lib/gitlab/database/partitioning/partition_monitoring_spec.rb b/spec/lib/gitlab/database/partitioning/partition_monitoring_spec.rb
new file mode 100644
index 00000000000..67596211f71
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning/partition_monitoring_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Partitioning::PartitionMonitoring do
+ describe '#report_metrics' do
+ subject { described_class.new(models).report_metrics }
+
+ let(:models) { [model] }
+ let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table) }
+ let(:partitioning_strategy) { double(missing_partitions: missing_partitions, current_partitions: current_partitions) }
+ let(:table) { "some_table" }
+
+ let(:missing_partitions) do
+ [double]
+ end
+
+ let(:current_partitions) do
+ [double, double]
+ end
+
+ it 'reports number of present partitions' do
+ subject
+
+ expect(Gitlab::Metrics.registry.get(:db_partitions_present).get({ table: table })).to eq(current_partitions.size)
+ end
+
+ it 'reports number of missing partitions' do
+ subject
+
+ expect(Gitlab::Metrics.registry.get(:db_partitions_missing).get({ table: table })).to eq(missing_partitions.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 49f3f87fe61..ec3d0a6dbcb 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
@@ -107,6 +107,15 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition
end.to change { ::Gitlab::Database::BackgroundMigrationJob.succeeded.count }.from(0).to(1)
end
+ it 'returns the number of job records marked as succeeded' do
+ create(:background_migration_job, class_name: "::#{described_class.name}",
+ arguments: [source1.id, source3.id, source_table, destination_table, unique_key])
+
+ jobs_updated = subject.perform(source1.id, source3.id, source_table, destination_table, unique_key)
+
+ expect(jobs_updated).to eq(1)
+ end
+
context 'when the feature flag is disabled' do
let(:mock_connection) { double('connection') }
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 86f79b213ae..44ef0b307fe 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
@@ -480,6 +480,153 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
end
+ describe '#finalize_backfilling_partitioned_table' do
+ let(:source_table) { 'todos' }
+ let(:source_column) { 'id' }
+
+ context 'when the table is not allowed' do
+ let(:source_table) { :this_table_is_not_allowed }
+
+ it 'raises an error' do
+ expect(migration).to receive(:assert_table_is_allowed).with(source_table).and_call_original
+
+ expect do
+ migration.finalize_backfilling_partitioned_table source_table
+ end.to raise_error(/#{source_table} is not allowed for use/)
+ end
+ end
+
+ context 'when the partitioned table does not exist' do
+ it 'raises an error' do
+ expect(migration).to receive(:table_exists?).with(partitioned_table).and_return(false)
+
+ expect do
+ migration.finalize_backfilling_partitioned_table source_table
+ end.to raise_error(/could not find partitioned table for #{source_table}/)
+ end
+ end
+
+ context 'finishing pending background migration jobs' do
+ let(:source_table_double) { double('table name') }
+ let(:raw_arguments) { [1, 50_000, source_table_double, partitioned_table, source_column] }
+
+ 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/)
+ 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(raw_arguments)
+
+ expect(source_table_double).to receive(:==).with(source_table.to_s)
+
+ 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!(:todo1) { create(:todo, created_at: timestamp, updated_at: timestamp) }
+ let!(:todo2) { create(:todo, created_at: timestamp, updated_at: timestamp) }
+ let!(:todo3) { create(:todo, created_at: timestamp, updated_at: timestamp) }
+ let!(:todo4) { create(:todo, created_at: timestamp, updated_at: timestamp) }
+
+ let!(:pending_job1) do
+ create(:background_migration_job,
+ class_name: described_class::MIGRATION_CLASS_NAME,
+ arguments: [todo1.id, todo2.id, source_table, partitioned_table, source_column])
+ end
+
+ let!(:pending_job2) do
+ create(:background_migration_job,
+ class_name: described_class::MIGRATION_CLASS_NAME,
+ arguments: [todo3.id, todo3.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: [todo4.id, todo4.id, source_table, partitioned_table, source_column])
+ 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(:execute).with(/VACUUM/)
+ end
+
+ it 'idempotently cleans up after failed background migrations' do
+ expect(partitioned_model.count).to eq(0)
+
+ partitioned_model.insert!(todo2.attributes)
+
+ 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(todo1.id, todo2.id, source_table, partitioned_table, source_column)
+ .and_call_original
+
+ expect(backfill).to receive(:perform)
+ .with(todo3.id, todo3.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)
+
+ [todo1, todo2, todo3].each do |original|
+ copy = partitioned_model.find(original.id)
+ expect(copy.attributes).to eq(original.attributes)
+ end
+
+ expect(partitioned_model.find_by_id(todo4.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
+
+ expect(migration).to receive(:disable_statement_timeout).and_call_original
+ expect(migration).to receive(:execute).with("VACUUM FREEZE ANALYZE #{partitioned_table}")
+
+ migration.finalize_backfilling_partitioned_table source_table
+ end
+ end
+ end
+
def filter_columns_by_name(columns, names)
columns.reject { |c| names.include?(c.name) }
end
diff --git a/spec/lib/gitlab/database/schema_cleaner_spec.rb b/spec/lib/gitlab/database/schema_cleaner_spec.rb
index 1303ad7a311..950759c7f96 100644
--- a/spec/lib/gitlab/database/schema_cleaner_spec.rb
+++ b/spec/lib/gitlab/database/schema_cleaner_spec.rb
@@ -15,8 +15,8 @@ RSpec.describe Gitlab::Database::SchemaCleaner do
expect(subject).not_to include('COMMENT ON EXTENSION')
end
- it 'sets the search_path' do
- expect(subject.split("\n").first).to eq('SET search_path=public;')
+ it 'no assumption about public being the default schema' do
+ expect(subject).not_to match(/public\.\w+/)
end
it 'cleans up the full schema as expected (blackbox test with example)' do
diff --git a/spec/lib/gitlab/database_importers/common_metrics/prometheus_metric_spec.rb b/spec/lib/gitlab/database_importers/common_metrics/prometheus_metric_spec.rb
index 67da59d6477..98a8e144d16 100644
--- a/spec/lib/gitlab/database_importers/common_metrics/prometheus_metric_spec.rb
+++ b/spec/lib/gitlab/database_importers/common_metrics/prometheus_metric_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Gitlab::DatabaseImporters::CommonMetrics::PrometheusMetric do
end
it '.group_titles equals ::PrometheusMetric' do
- existing_group_titles = ::PrometheusMetricEnums.group_details.transform_values do |value|
+ existing_group_titles = Enums::PrometheusMetric.group_details.transform_values do |value|
value[:group_title]
end
expect(Gitlab::DatabaseImporters::CommonMetrics::PrometheusMetricEnums.group_titles).to eq(existing_group_titles)
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
index a3661bbe49a..39029322e25 100644
--- a/spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb
+++ b/spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb
@@ -65,8 +65,8 @@ RSpec.describe Gitlab::DatabaseImporters::InstanceAdministrators::CreateGroup do
it 'creates group' do
expect(result[:status]).to eq(:success)
expect(group).to be_persisted
- expect(group.name).to eq('GitLab Instance Administrators')
- expect(group.path).to start_with('gitlab-instance-administrators')
+ 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
diff --git a/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb b/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb
index 9f10811d765..30981e4bd7d 100644
--- a/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb
+++ b/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb
@@ -33,9 +33,9 @@ RSpec.describe Gitlab::DiscussionsDiff::HighlightCache, :clean_gitlab_redis_cach
mapping.each do |key, value|
full_key = described_class.cache_key_for(key)
- found = Gitlab::Redis::Cache.with { |r| r.get(full_key) }
+ found_key = Gitlab::Redis::Cache.with { |r| r.get(full_key) }
- expect(found).to eq(value.to_json)
+ expect(described_class.gzip_decompress(found_key)).to eq(value.to_json)
end
end
end
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 ee2173a9c8d..1a7d837af73 100644
--- a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
@@ -4,17 +4,6 @@ require 'spec_helper'
RSpec.describe Gitlab::Email::Handler::CreateIssueHandler do
include_context :email_shared_context
- it_behaves_like :reply_processing_shared_examples
-
- before do
- stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.adventuretime.ooo")
- stub_config_setting(host: 'localhost')
- end
-
- let(:email_raw) { email_fixture('emails/valid_new_issue.eml') }
- let(:namespace) { create(:namespace, path: 'gitlabhq') }
-
- let!(:project) { create(:project, :public, namespace: namespace, path: 'gitlabhq') }
let!(:user) do
create(
:user,
@@ -23,6 +12,17 @@ RSpec.describe Gitlab::Email::Handler::CreateIssueHandler do
)
end
+ let!(:project) { create(:project, :public, namespace: namespace, path: 'gitlabhq') }
+ let(:namespace) { create(:namespace, path: 'gitlabhq') }
+ let(:email_raw) { email_fixture('emails/valid_new_issue.eml') }
+
+ it_behaves_like :reply_processing_shared_examples
+
+ before do
+ stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.adventuretime.ooo")
+ stub_config_setting(host: 'localhost')
+ end
+
context "when email key" do
let(:mail) { Mail::Message.new(email_raw) }
diff --git a/spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb b/spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb
index 75d5fc040cb..37ee4591db0 100644
--- a/spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb
@@ -4,6 +4,18 @@ require 'spec_helper'
RSpec.describe Gitlab::Email::Handler::CreateMergeRequestHandler do
include_context :email_shared_context
+ let!(:user) do
+ create(
+ :user,
+ email: 'jake@adventuretime.ooo',
+ incoming_email_token: 'auth_token'
+ )
+ end
+
+ let!(:project) { create(:project, :public, :repository, namespace: namespace, path: 'gitlabhq') }
+ let(:namespace) { create(:namespace, path: 'gitlabhq') }
+ let(:email_raw) { email_fixture('emails/valid_new_merge_request.eml') }
+
it_behaves_like :reply_processing_shared_examples
before do
@@ -15,18 +27,6 @@ RSpec.describe Gitlab::Email::Handler::CreateMergeRequestHandler do
TestEnv.clean_test_path
end
- let(:email_raw) { email_fixture('emails/valid_new_merge_request.eml') }
- let(:namespace) { create(:namespace, path: 'gitlabhq') }
-
- let!(:project) { create(:project, :public, :repository, namespace: namespace, path: 'gitlabhq') }
- let!(:user) do
- create(
- :user,
- email: 'jake@adventuretime.ooo',
- incoming_email_token: 'auth_token'
- )
- end
-
context "when email key" do
let(:mail) { Mail::Message.new(email_raw) }
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 e5598bbd10f..07b8070be30 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -4,6 +4,16 @@ require 'spec_helper'
RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
include_context :email_shared_context
+ let!(:sent_notification) do
+ SentNotification.record_note(note, user.id, mail_key)
+ end
+
+ let(:noteable) { note.noteable }
+ let(:note) { create(:diff_note_on_merge_request, project: project) }
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:email_raw) { fixture_file('emails/valid_reply.eml') }
+
it_behaves_like :reply_processing_shared_examples
before do
@@ -11,16 +21,6 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
stub_config_setting(host: 'localhost')
end
- let(:email_raw) { fixture_file('emails/valid_reply.eml') }
- let(:project) { create(:project, :public, :repository) }
- let(:user) { create(:user) }
- let(:note) { create(:diff_note_on_merge_request, project: project) }
- let(:noteable) { note.noteable }
-
- let!(:sent_notification) do
- SentNotification.record_note(note, user.id, mail_key)
- end
-
context "when the recipient address doesn't include a mail key" do
let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "") }
diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb
index 592d3f3f0e4..ccff902d290 100644
--- a/spec/lib/gitlab/email/receiver_spec.rb
+++ b/spec/lib/gitlab/email/receiver_spec.rb
@@ -36,6 +36,12 @@ RSpec.describe Gitlab::Email::Receiver do
it_behaves_like 'correctly finds the mail key'
end
+ context 'when in an X-Envelope-To header' do
+ let(:email_raw) { fixture_file('emails/x_envelope_to_header.eml') }
+
+ it_behaves_like 'correctly finds the mail key'
+ end
+
context 'when enclosed with angle brackets in an Envelope-To header' do
let(:email_raw) { fixture_file('emails/envelope_to_header_with_angle_brackets.eml') }
diff --git a/spec/lib/gitlab/error_tracking/processor/grpc_error_processor_spec.rb b/spec/lib/gitlab/error_tracking/processor/grpc_error_processor_spec.rb
new file mode 100644
index 00000000000..797707114a1
--- /dev/null
+++ b/spec/lib/gitlab/error_tracking/processor/grpc_error_processor_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::ErrorTracking::Processor::GrpcErrorProcessor do
+ describe '#process' do
+ subject { described_class.new }
+
+ context 'when there is no GRPC exception' do
+ let(:data) { { fingerprint: ['ArgumentError', 'Missing arguments'] } }
+
+ it 'leaves data unchanged' do
+ expect(subject.process(data)).to eq(data)
+ end
+ end
+
+ context 'when there is a GPRC exception with a debug string' do
+ let(:data) do
+ {
+ exception: {
+ values: [
+ {
+ type: "GRPC::DeadlineExceeded",
+ value: "4:DeadlineExceeded. debug_error_string:{\"hello\":1}"
+ }
+ ]
+ },
+ extra: {
+ caller: 'test'
+ },
+ fingerprint: [
+ "GRPC::DeadlineExceeded",
+ "4:Deadline Exceeded. debug_error_string:{\"created\":\"@1598938192.005782000\",\"description\":\"Error received from peer unix:/home/git/gitalypraefect.socket\",\"file\":\"src/core/lib/surface/call.cc\",\"file_line\":1055,\"grpc_message\":\"Deadline Exceeded\",\"grpc_status\":4}"
+ ]
+ }
+ end
+
+ let(:expected) do
+ {
+ fingerprint: [
+ "GRPC::DeadlineExceeded",
+ "4:Deadline Exceeded."
+ ],
+ exception: {
+ values: [
+ {
+ type: "GRPC::DeadlineExceeded",
+ value: "4:DeadlineExceeded."
+ }
+ ]
+ },
+ extra: {
+ caller: 'test',
+ grpc_debug_error_string: "{\"hello\":1}"
+ }
+ }
+ end
+
+ it 'removes the debug error string and stores it as an extra field' do
+ expect(subject.process(data)).to eq(expected)
+ end
+
+ context 'with no custom fingerprint' do
+ before do
+ data.delete(:fingerprint)
+ expected.delete(:fingerprint)
+ end
+
+ it 'removes the debug error string and stores it as an extra field' do
+ expect(subject.process(data)).to eq(expected)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb
index 2de5e1e20d6..9bc865f4d29 100644
--- a/spec/lib/gitlab/experimentation_spec.rb
+++ b/spec/lib/gitlab/experimentation_spec.rb
@@ -295,6 +295,19 @@ RSpec.describe Gitlab::Experimentation do
end
end
end
+
+ describe '#experiment_tracking_category_and_group' do
+ let_it_be(:experiment_key) { :test_something }
+
+ subject { controller.experiment_tracking_category_and_group(experiment_key) }
+
+ it 'returns a string with the experiment tracking category & group joined with a ":"' do
+ expect(controller).to receive(:tracking_category).with(experiment_key).and_return('Experiment::Category')
+ expect(controller).to receive(:tracking_group).with(experiment_key, '_group').and_return('experimental_group')
+
+ expect(subject).to eq('Experiment::Category:experimental_group')
+ end
+ end
end
describe '.enabled?' do
diff --git a/spec/lib/gitlab/external_authorization/access_spec.rb b/spec/lib/gitlab/external_authorization/access_spec.rb
index 4bb81230ac0..a6773cc19e1 100644
--- a/spec/lib/gitlab/external_authorization/access_spec.rb
+++ b/spec/lib/gitlab/external_authorization/access_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Gitlab::ExternalAuthorization::Access, :clean_gitlab_redis_cache
describe '#loaded?' do
it 'is `true` when it was loaded recently' do
- Timecop.freeze do
+ freeze_time do
allow(access).to receive(:loaded_at).and_return(5.minutes.ago)
expect(access).to be_loaded
@@ -19,7 +19,7 @@ RSpec.describe Gitlab::ExternalAuthorization::Access, :clean_gitlab_redis_cache
end
it 'is `false` when there the result was loaded a long time ago' do
- Timecop.freeze do
+ freeze_time do
allow(access).to receive(:loaded_at).and_return(2.weeks.ago)
expect(access).not_to be_loaded
@@ -70,7 +70,7 @@ RSpec.describe Gitlab::ExternalAuthorization::Access, :clean_gitlab_redis_cache
end
it 'stores the result in redis' do
- Timecop.freeze do
+ freeze_time do
fake_cache = double
expect(fake_cache).to receive(:store).with(true, nil, Time.now)
expect(access).to receive(:cache).and_return(fake_cache)
@@ -118,7 +118,7 @@ RSpec.describe Gitlab::ExternalAuthorization::Access, :clean_gitlab_redis_cache
end
it 'does not load from the webservice' do
- Timecop.freeze do
+ freeze_time do
expect(fake_cache).to receive(:load).and_return([true, nil, Time.now])
expect(access).to receive(:load_from_cache).and_call_original
@@ -129,7 +129,7 @@ RSpec.describe Gitlab::ExternalAuthorization::Access, :clean_gitlab_redis_cache
end
it 'loads from the webservice when the cached result was too old' do
- Timecop.freeze do
+ freeze_time do
expect(fake_cache).to receive(:load).and_return([true, nil, 2.days.ago])
expect(access).to receive(:load_from_cache).and_call_original
diff --git a/spec/lib/gitlab/external_authorization/cache_spec.rb b/spec/lib/gitlab/external_authorization/cache_spec.rb
index 9037c04cf2b..a8e7932b82c 100644
--- a/spec/lib/gitlab/external_authorization/cache_spec.rb
+++ b/spec/lib/gitlab/external_authorization/cache_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Gitlab::ExternalAuthorization::Cache, :clean_gitlab_redis_cache d
describe '#load' do
it 'reads stored info from redis' do
- Timecop.freeze do
+ freeze_time do
set_in_redis(:access, false)
set_in_redis(:reason, 'Access denied for now')
set_in_redis(:refreshed_at, Time.now)
@@ -38,7 +38,7 @@ RSpec.describe Gitlab::ExternalAuthorization::Cache, :clean_gitlab_redis_cache d
describe '#store' do
it 'sets the values in redis' do
- Timecop.freeze do
+ freeze_time do
cache.store(true, 'the reason', Time.now)
expect(read_from_redis(:access)).to eq('true')
diff --git a/spec/lib/gitlab/file_type_detection_spec.rb b/spec/lib/gitlab/file_type_detection_spec.rb
index ba5e7cfabf2..c435d3f6097 100644
--- a/spec/lib/gitlab/file_type_detection_spec.rb
+++ b/spec/lib/gitlab/file_type_detection_spec.rb
@@ -192,6 +192,20 @@ RSpec.describe Gitlab::FileTypeDetection do
end
end
+ describe '#image_safe_for_scaling?' do
+ it 'returns true for allowed image formats' do
+ uploader.store!(upload_fixture('rails_sample.jpg'))
+
+ expect(uploader).to be_image_safe_for_scaling
+ end
+
+ it 'returns false for other files' do
+ uploader.store!(upload_fixture('unsanitized.svg'))
+
+ expect(uploader).not_to be_image_safe_for_scaling
+ end
+ end
+
describe '#dangerous_image?' do
it 'returns true if filename has a dangerous extension' do
uploader.store!(upload_fixture('unsanitized.svg'))
@@ -377,6 +391,31 @@ RSpec.describe Gitlab::FileTypeDetection do
end
end
+ describe '#image_safe_for_scaling?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:filename, :expectation) do
+ 'img.jpg' | true
+ 'img.jpeg' | true
+ 'img.png' | true
+ 'img.svg' | false
+ end
+
+ with_them do
+ it "returns expected result" do
+ allow(custom_class).to receive(:filename).and_return(filename)
+
+ expect(custom_class.image_safe_for_scaling?).to be(expectation)
+ end
+ end
+
+ it 'returns false if filename is blank' do
+ allow(custom_class).to receive(:filename).and_return(nil)
+
+ expect(custom_class).not_to be_image_safe_for_scaling
+ end
+ end
+
describe '#video?' do
it 'returns true for a video file' do
allow(custom_class).to receive(:filename).and_return('video_sample.mp4')
diff --git a/spec/lib/gitlab/git/base_error_spec.rb b/spec/lib/gitlab/git/base_error_spec.rb
new file mode 100644
index 00000000000..851cfa16512
--- /dev/null
+++ b/spec/lib/gitlab/git/base_error_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rspec-parameterized'
+
+RSpec.describe Gitlab::Git::BaseError do
+ using RSpec::Parameterized::TableSyntax
+
+ subject { described_class.new(message).to_s }
+
+ where(:message, :result) do
+ "GRPC::DeadlineExceeded: 4:DeadlineExceeded. debug_error_string:{\"hello\":1}" | "GRPC::DeadlineExceeded: 4:DeadlineExceeded."
+ "GRPC::DeadlineExceeded: 4:DeadlineExceeded." | "GRPC::DeadlineExceeded: 4:DeadlineExceeded."
+ "GRPC::DeadlineExceeded: 4:DeadlineExceeded. debug_error_string:{\"created\":\"@1598978902.544524530\",\"description\":\"Error received from peer ipv4: debug_error_string:test\"}" | "GRPC::DeadlineExceeded: 4:DeadlineExceeded."
+ "9:Multiple lines\nTest line. debug_error_string:{\"created\":\"@1599074877.106467000\"}" | "9:Multiple lines\nTest line."
+ "other message" | "other message"
+ nil | "Gitlab::Git::BaseError"
+ end
+
+ with_them do
+ it { is_expected.to eq(result) }
+ end
+end
diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb
index 491437856d4..8961cdcae7d 100644
--- a/spec/lib/gitlab/git/commit_spec.rb
+++ b/spec/lib/gitlab/git/commit_spec.rb
@@ -192,7 +192,7 @@ RSpec.describe Gitlab::Git::Commit, :seed_helper do
end
describe '.find with Gitaly enabled' do
- it_should_behave_like '.find'
+ it_behaves_like '.find'
end
describe '.find with Rugged enabled', :enable_rugged do
@@ -204,7 +204,7 @@ RSpec.describe Gitlab::Git::Commit, :seed_helper do
described_class.find(repository, SeedRepo::Commit::ID)
end
- it_should_behave_like '.find'
+ it_behaves_like '.find'
end
describe '.last_for_path' do
@@ -474,7 +474,7 @@ RSpec.describe Gitlab::Git::Commit, :seed_helper do
end
describe '.batch_by_oid with Gitaly enabled' do
- it_should_behave_like '.batch_by_oid'
+ it_behaves_like '.batch_by_oid'
context 'when oids is empty' do
it 'makes no Gitaly request' do
@@ -486,7 +486,7 @@ RSpec.describe Gitlab::Git::Commit, :seed_helper do
end
describe '.batch_by_oid with Rugged enabled', :enable_rugged do
- it_should_behave_like '.batch_by_oid'
+ it_behaves_like '.batch_by_oid'
it 'calls out to the Rugged implementation' do
allow_next_instance_of(Rugged) do |instance|
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 4d3245fc988..6d143f78c66 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
@@ -38,7 +38,7 @@ RSpec.describe Gitlab::GithubImport::Importer::LabelLinksImporter do
.to receive(:find_target_id)
.and_return(1)
- Timecop.freeze do
+ freeze_time do
expect(Gitlab::Database)
.to receive(:bulk_insert)
.with(
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 0010b959a49..ca9d3e1e21c 100644
--- a/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb
@@ -85,13 +85,13 @@ RSpec.describe Gitlab::GithubImport::Importer::LabelsImporter, :clean_gitlab_red
end
it 'includes the created timestamp' do
- Timecop.freeze do
+ freeze_time do
expect(label_hash[:created_at]).to eq(Time.zone.now)
end
end
it 'includes the updated timestamp' do
- Timecop.freeze do
+ freeze_time do
expect(label_hash[:updated_at]).to eq(Time.zone.now)
end
end
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb
index 05ac0248ec9..0835c6155b9 100644
--- a/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb
@@ -164,7 +164,7 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestsImporter do
.to receive(:increment)
.and_call_original
- Timecop.freeze do
+ freeze_time do
importer.update_repository
expect(project.last_repository_updated_at).to be_like_time(Time.zone.now)
diff --git a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
index 65dba2711b9..180c6d9e420 100644
--- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
@@ -261,7 +261,7 @@ RSpec.describe Gitlab::GithubImport::Importer::RepositoryImporter do
describe '#update_clone_time' do
it 'sets the timestamp for when the cloning process finished' do
- Timecop.freeze do
+ freeze_time do
expect(project)
.to receive(:update_column)
.with(:last_repository_updated_at, Time.zone.now)
diff --git a/spec/lib/gitlab/github_import/label_finder_spec.rb b/spec/lib/gitlab/github_import/label_finder_spec.rb
index 452f3c896a4..9905fce2a20 100644
--- a/spec/lib/gitlab/github_import/label_finder_spec.rb
+++ b/spec/lib/gitlab/github_import/label_finder_spec.rb
@@ -3,10 +3,10 @@
require 'spec_helper'
RSpec.describe Gitlab::GithubImport::LabelFinder, :clean_gitlab_redis_cache do
- let(:project) { create(:project) }
- let(:finder) { described_class.new(project) }
- let!(:bug) { create(:label, project: project, name: 'Bug') }
- let!(:feature) { create(:label, project: project, name: 'Feature') }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:finder) { described_class.new(project) }
+ let_it_be(:bug) { create(:label, project: project, name: 'Bug') }
+ let_it_be(:feature) { create(:label, project: project, name: 'Feature') }
describe '#id_for' do
context 'with a cache in place' do
diff --git a/spec/lib/gitlab/github_import/milestone_finder_spec.rb b/spec/lib/gitlab/github_import/milestone_finder_spec.rb
index 419184d6115..5da45b1897f 100644
--- a/spec/lib/gitlab/github_import/milestone_finder_spec.rb
+++ b/spec/lib/gitlab/github_import/milestone_finder_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe Gitlab::GithubImport::MilestoneFinder, :clean_gitlab_redis_cache do
- let!(:project) { create(:project) }
- let!(:milestone) { create(:milestone, project: project) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:milestone) { create(:milestone, project: project) }
let(:finder) { described_class.new(project) }
describe '#id_for' do
diff --git a/spec/lib/gitlab/gitpod_spec.rb b/spec/lib/gitlab/gitpod_spec.rb
new file mode 100644
index 00000000000..f4dda42aeb4
--- /dev/null
+++ b/spec/lib/gitlab/gitpod_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Gitpod do
+ let_it_be(:user) { create(:user) }
+ let(:feature_scope) { true }
+
+ before do
+ stub_feature_flags(gitpod: feature_scope)
+ end
+
+ describe '.feature_conditional?' do
+ subject { described_class.feature_conditional? }
+
+ context 'when feature is enabled globally' do
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when feature is enabled only to a resource' do
+ let(:feature_scope) { user }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '.feature_available?' do
+ subject { described_class.feature_available? }
+
+ context 'when feature is enabled globally' do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when feature is enabled only to a resource' do
+ let(:feature_scope) { user }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '.feature_enabled?' do
+ let(:current_user) { nil }
+
+ subject { described_class.feature_enabled?(current_user) }
+
+ context 'when feature is enabled globally' do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when feature is enabled only to a resource' do
+ let(:feature_scope) { user }
+
+ context 'for the same resource' do
+ let(:current_user) { user }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'for a different resource' do
+ let(:current_user) { create(:user) }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+ 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 e920fc7cd3b..3fa636a1cf0 100644
--- a/spec/lib/gitlab/gl_repository/repo_type_spec.rb
+++ b/spec/lib/gitlab/gl_repository/repo_type_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Gitlab::GlRepository::RepoType do
let(:expected_identifier) { "project-#{expected_id}" }
let(:expected_suffix) { '' }
let(:expected_container) { project }
- let(:expected_repository) { expected_container.repository }
+ let(:expected_repository) { ::Repository.new(project.full_path, project, shard: project.repository_storage, disk_path: project.disk_path, repo_type: Gitlab::GlRepository::PROJECT) }
end
it 'knows its type' do
@@ -46,7 +46,7 @@ RSpec.describe Gitlab::GlRepository::RepoType do
let(:expected_identifier) { "wiki-#{expected_id}" }
let(:expected_suffix) { '.wiki' }
let(:expected_container) { project }
- let(:expected_repository) { expected_container.wiki.repository }
+ let(:expected_repository) { ::Repository.new(project.wiki.full_path, project, shard: project.wiki.repository_storage, disk_path: project.wiki.disk_path, repo_type: Gitlab::GlRepository::WIKI) }
end
it 'knows its type' do
@@ -75,7 +75,7 @@ RSpec.describe Gitlab::GlRepository::RepoType do
let(:expected_id) { personal_snippet.id }
let(:expected_identifier) { "snippet-#{expected_id}" }
let(:expected_suffix) { '' }
- let(:expected_repository) { personal_snippet.repository }
+ let(:expected_repository) { ::Repository.new(personal_snippet.full_path, personal_snippet, shard: personal_snippet.repository_storage, disk_path: personal_snippet.disk_path, repo_type: Gitlab::GlRepository::SNIPPET) }
let(:expected_container) { personal_snippet }
end
@@ -104,7 +104,7 @@ RSpec.describe Gitlab::GlRepository::RepoType do
let(:expected_id) { project_snippet.id }
let(:expected_identifier) { "snippet-#{expected_id}" }
let(:expected_suffix) { '' }
- let(:expected_repository) { project_snippet.repository }
+ let(:expected_repository) { ::Repository.new(project_snippet.full_path, project_snippet, shard: project_snippet.repository_storage, disk_path: project_snippet.disk_path, repo_type: Gitlab::GlRepository::SNIPPET) }
let(:expected_container) { project_snippet }
end
@@ -133,10 +133,14 @@ RSpec.describe Gitlab::GlRepository::RepoType do
let(:expected_identifier) { "design-#{project.id}" }
let(:expected_id) { project.id }
let(:expected_suffix) { '.design' }
- let(:expected_repository) { project.design_repository }
+ let(:expected_repository) { ::DesignManagement::Repository.new(project) }
let(:expected_container) { project }
end
+ it 'uses the design access checker' do
+ expect(described_class.access_checker_class).to eq(::Gitlab::GitAccessDesign)
+ end
+
it 'knows its type' do
aggregate_failures do
expect(described_class).to be_design
diff --git a/spec/lib/gitlab/gl_repository_spec.rb b/spec/lib/gitlab/gl_repository_spec.rb
index f90103ee6f7..3733d545155 100644
--- a/spec/lib/gitlab/gl_repository_spec.rb
+++ b/spec/lib/gitlab/gl_repository_spec.rb
@@ -31,15 +31,4 @@ RSpec.describe ::Gitlab::GlRepository do
expect { described_class.parse("project-foo") }.to raise_error(ArgumentError)
end
end
-
- describe 'DESIGN' do
- it 'uses the design access checker' do
- expect(described_class::DESIGN.access_checker_class).to eq(::Gitlab::GitAccessDesign)
- end
-
- it 'builds a design repository' do
- expect(described_class::DESIGN.repository_resolver.call(create(:project)))
- .to be_a(::DesignManagement::Repository)
- end
- end
end
diff --git a/spec/lib/gitlab/graphql/docs/renderer_spec.rb b/spec/lib/gitlab/graphql/docs/renderer_spec.rb
index 81ef7fcda97..d1be962a4f8 100644
--- a/spec/lib/gitlab/graphql/docs/renderer_spec.rb
+++ b/spec/lib/gitlab/graphql/docs/renderer_spec.rb
@@ -36,10 +36,10 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
specify do
expectation = <<~DOC
- ## ArrayTest
+ ### ArrayTest
- | Name | Type | Description |
- | --- | ---- | ---------- |
+ | Field | Type | Description |
+ | ----- | ---- | ----------- |
| `foo` | String! => Array | A description |
DOC
@@ -59,10 +59,10 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
specify do
expectation = <<~DOC
- ## OrderingTest
+ ### OrderingTest
- | Name | Type | Description |
- | --- | ---- | ---------- |
+ | Field | Type | Description |
+ | ----- | ---- | ----------- |
| `bar` | String! | A description of bar field |
| `foo` | String! | A description of foo field |
DOC
@@ -82,15 +82,45 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
specify do
expectation = <<~DOC
- ## DeprecatedTest
+ ### DeprecatedTest
- | Name | Type | Description |
- | --- | ---- | ---------- |
+ | Field | Type | Description |
+ | ----- | ---- | ----------- |
| `foo` **{warning-solid}** | String! | **Deprecated:** This is deprecated. Deprecated in 1.10 |
DOC
is_expected.to include(expectation)
end
end
+
+ context 'A type with an emum field' do
+ let(:type) do
+ enum_type = Class.new(Types::BaseEnum) do
+ graphql_name 'MyEnum'
+
+ value 'BAZ', description: 'A description of BAZ'
+ value 'BAR', description: 'A description of BAR', deprecated: { reason: 'This is deprecated', milestone: '1.10' }
+ end
+
+ Class.new(Types::BaseObject) do
+ graphql_name 'EnumTest'
+
+ field :foo, enum_type, null: false, description: 'A description of foo field'
+ end
+ end
+
+ specify do
+ expectation = <<~DOC
+ ### MyEnum
+
+ | Value | Description |
+ | ----- | ----------- |
+ | `BAR` **{warning-solid}** | **Deprecated:** This is deprecated. Deprecated in 1.10 |
+ | `BAZ` | A description of BAZ |
+ DOC
+
+ is_expected.to include(expectation)
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/graphql/loaders/issuable_loader_spec.rb b/spec/lib/gitlab/graphql/loaders/issuable_loader_spec.rb
index 180966de895..33a9d40931e 100644
--- a/spec/lib/gitlab/graphql/loaders/issuable_loader_spec.rb
+++ b/spec/lib/gitlab/graphql/loaders/issuable_loader_spec.rb
@@ -6,9 +6,26 @@ RSpec.describe Gitlab::Graphql::Loaders::IssuableLoader do
subject { described_class.new(parent, finder) }
let(:params) { HashWithIndifferentAccess.new }
+ let(:finder_params) { finder.params.to_h.with_indifferent_access }
+
+ # Dumb finder class, that only implements what we need, and has
+ # predictable query counts.
+ let(:finder_class) do
+ Class.new(IssuesFinder) do
+ def execute
+ params[:project_id].issues.where(iid: params[:iids])
+ end
+
+ private
+
+ def params_class
+ IssuesFinder::Params
+ end
+ end
+ end
describe '#find_all' do
- let(:finder) { double(:finder, params: params, execute: %i[x y z]) }
+ let(:finder) { issuable_finder(params: params, result: [:x, :y, :z]) }
where(:factory, :param_name) do
%i[project group].map { |thing| [thing, :"#{thing}_id"] }
@@ -19,7 +36,7 @@ RSpec.describe Gitlab::Graphql::Loaders::IssuableLoader do
it 'assignes the parent parameter, and batching_find_alls the finder' do
expect(subject.find_all).to contain_exactly(:x, :y, :z)
- expect(params).to include(param_name => parent)
+ expect(finder_params).to include(param_name => parent)
end
end
@@ -34,12 +51,12 @@ RSpec.describe Gitlab::Graphql::Loaders::IssuableLoader do
describe '#batching_find_all' do
context 'the finder params are anything other than [iids]' do
- let(:finder) { double(:finder, params: params, execute: [:foo]) }
+ let(:finder) { issuable_finder(params: params, result: [:foo]) }
let(:parent) { build_stubbed(:project) }
it 'batching_find_alls the finder, setting the correct parent parameter' do
expect(subject.batching_find_all).to eq([:foo])
- expect(params[:project_id]).to eq(parent)
+ expect(finder_params[:project_id]).to eq(parent)
end
it 'allows a post-process block' do
@@ -48,23 +65,6 @@ RSpec.describe Gitlab::Graphql::Loaders::IssuableLoader do
end
context 'the finder params are exactly [iids]' do
- # Dumb finder class, that only implements what we need, and has
- # predictable query counts.
- let(:finder_class) do
- Class.new do
- attr_reader :current_user, :params
-
- def initialize(user, args)
- @current_user = user
- @params = HashWithIndifferentAccess.new(args.to_h)
- end
-
- def execute
- params[:project_id].issues.where(iid: params[:iids])
- end
- end
- end
-
it 'batches requests' do
issue_a = create(:issue)
issue_b = create(:issue)
@@ -93,4 +93,13 @@ RSpec.describe Gitlab::Graphql::Loaders::IssuableLoader do
end
end
end
+
+ private
+
+ def issuable_finder(user: double(:user), params: {}, result: nil)
+ new_finder = finder_class.new(user, params)
+ allow(new_finder).to receive(:execute).and_return(result) if result
+
+ new_finder
+ end
end
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
index 09d7e084172..c8f368b15fc 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
@@ -262,6 +262,22 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
end
end
+ context 'when ordering by similarity' do
+ let!(:project1) { create(:project, name: 'test') }
+ let!(:project2) { create(:project, name: 'testing') }
+ let!(:project3) { create(:project, name: 'tests') }
+ let!(:project4) { create(:project, name: 'testing stuff') }
+ let!(:project5) { create(:project, name: 'test') }
+
+ let(:nodes) do
+ Project.sorted_by_similarity_desc('test', include_in_select: true)
+ end
+
+ let(:descending_nodes) { nodes.to_a }
+
+ it_behaves_like 'nodes are in descending order'
+ end
+
context 'when an invalid cursor is provided' do
let(:arguments) { { before: Base64Bp.urlsafe_encode64('invalidcursor', padding: false) } }
@@ -358,15 +374,6 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
end
end
- context 'when before and last does not request all remaining nodes' do
- let(:arguments) { { before: encoded_cursor(project_list.last), last: 2 } }
-
- it 'has a previous and a next' do
- expect(subject.has_previous_page).to be_truthy
- expect(subject.has_next_page).to be_truthy
- end
- end
-
context 'when before and last does request all remaining nodes' do
let(:arguments) { { before: encoded_cursor(project_list[1]), last: 3 } }
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
index 9f310f30253..444c10074a0 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
@@ -51,6 +51,18 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::OrderInfo do
expect(order_list.last.operator_for(:after)).to eq '>'
end
end
+
+ context 'when ordering by SIMILARITY' do
+ let(:relation) { Project.sorted_by_similarity_desc('test', include_in_select: true) }
+
+ it 'assigns the right attribute name, named function, and direction' do
+ expect(order_list.count).to eq 2
+ expect(order_list.first.attribute_name).to eq 'similarity'
+ expect(order_list.first.named_function).to be_kind_of(Arel::Nodes::Addition)
+ expect(order_list.first.named_function.to_sql).to include 'SIMILARITY('
+ expect(order_list.first.sort_direction).to eq :desc
+ end
+ end
end
describe '#validate_ordering' do
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb
index 31c02fd43e8..c7e7db4d535 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb
@@ -131,5 +131,42 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::QueryBuilder do
end
end
end
+
+ context 'when sorting using SIMILARITY' do
+ let(:relation) { Project.sorted_by_similarity_desc('test', include_in_select: true) }
+ let(:arel_table) { Project.arel_table }
+ let(:decoded_cursor) { { 'similarity' => 0.5, 'id' => 100 } }
+ let(:similarity_sql) do
+ [
+ '(SIMILARITY(COALESCE("projects"."path", \'\'), \'test\') * CAST(\'1\' AS numeric))',
+ '(SIMILARITY(COALESCE("projects"."name", \'\'), \'test\') * CAST(\'0.7\' AS numeric))',
+ '(SIMILARITY(COALESCE("projects"."description", \'\'), \'test\') * CAST(\'0.2\' AS numeric))'
+ ].join(' + ')
+ end
+
+ context 'when no values are nil' do
+ context 'when :after' do
+ it 'generates the correct condition' do
+ conditions = builder.conditions.gsub(/\s+/, ' ')
+
+ expect(conditions).to include "(#{similarity_sql} < 0.5)"
+ expect(conditions).to include '"projects"."id" < 100'
+ expect(conditions).to include "OR (#{similarity_sql} IS NULL)"
+ end
+ end
+
+ context 'when :before' do
+ let(:before_or_after) { :before }
+
+ it 'generates the correct condition' do
+ conditions = builder.conditions.gsub(/\s+/, ' ')
+
+ expect(conditions).to include "(#{similarity_sql} > 0.5)"
+ expect(conditions).to include '"projects"."id" > 100'
+ expect(conditions).to include "OR ( #{similarity_sql} = 0.5"
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/group_search_results_spec.rb b/spec/lib/gitlab/group_search_results_spec.rb
index b6a3c8b5e76..045c922783a 100644
--- a/spec/lib/gitlab/group_search_results_spec.rb
+++ b/spec/lib/gitlab/group_search_results_spec.rb
@@ -3,10 +3,43 @@
require 'spec_helper'
RSpec.describe Gitlab::GroupSearchResults do
- let(:user) { create(:user) }
+ # group creation calls GroupFinder, so need to create the group
+ # before so expect(GroupsFinder) check works
+ let_it_be(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, group: group) }
+ let(:filters) { {} }
+ let(:limit_projects) { Project.all }
+ let(:query) { 'gob' }
+
+ subject(:results) { described_class.new(user, query, limit_projects, group: group, filters: filters) }
+
+ describe 'issues search' do
+ let_it_be(:opened_result) { create(:issue, :opened, project: project, title: 'foo opened') }
+ let_it_be(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') }
+ let(:query) { 'foo' }
+ let(:scope) { 'issues' }
+
+ include_examples 'search results filtered by state'
+ end
+
+ describe 'merge_requests search' do
+ let(:opened_result) { create(:merge_request, :opened, source_project: project, title: 'foo opened') }
+ let(:closed_result) { create(:merge_request, :closed, source_project: project, title: 'foo closed') }
+ let(:query) { 'foo' }
+ let(:scope) { 'merge_requests' }
+
+ before do
+ # we're creating those instances in before block because otherwise factory for MRs will fail on after(:build)
+ opened_result
+ closed_result
+ end
+
+ include_examples 'search results filtered by state'
+ end
describe 'user search' do
- let(:group) { create(:group) }
+ subject(:objects) { results.objects('users') }
it 'returns the users belonging to the group matching the search query' do
user1 = create(:user, username: 'gob_bluth')
@@ -17,9 +50,7 @@ RSpec.describe Gitlab::GroupSearchResults do
create(:user, username: 'gob_2018')
- result = described_class.new(user, anything, group, 'gob').objects('users')
-
- expect(result).to eq [user1]
+ is_expected.to eq [user1]
end
it 'returns the user belonging to the subgroup matching the search query' do
@@ -29,9 +60,7 @@ RSpec.describe Gitlab::GroupSearchResults do
create(:user, username: 'gob_2018')
- result = described_class.new(user, anything, group, 'gob').objects('users')
-
- expect(result).to eq [user1]
+ is_expected.to eq [user1]
end
it 'returns the user belonging to the parent group matching the search query' do
@@ -41,9 +70,7 @@ RSpec.describe Gitlab::GroupSearchResults do
create(:user, username: 'gob_2018')
- result = described_class.new(user, anything, group, 'gob').objects('users')
-
- expect(result).to eq [user1]
+ is_expected.to eq [user1]
end
it 'does not return the user belonging to the private subgroup' do
@@ -53,9 +80,7 @@ RSpec.describe Gitlab::GroupSearchResults do
create(:user, username: 'gob_2018')
- result = described_class.new(user, anything, group, 'gob').objects('users')
-
- expect(result).to eq []
+ is_expected.to be_empty
end
it 'does not return the user belonging to an unrelated group' do
@@ -63,15 +88,26 @@ RSpec.describe Gitlab::GroupSearchResults do
unrelated_group = create(:group)
create(:group_member, :developer, user: user, group: unrelated_group)
- result = described_class.new(user, anything, group, 'gob').objects('users')
+ is_expected.to be_empty
+ end
- expect(result).to eq []
+ it 'does not return the user invited to the group' do
+ user = create(:user, username: 'gob_bluth')
+ create(:group_member, :invited, :developer, user: user, group: group)
+
+ is_expected.to be_empty
end
- it 'sets include_subgroups flag by default' do
- result = described_class.new(user, anything, group, 'gob')
+ it 'calls GroupFinder during execution' do
+ expect(GroupsFinder).to receive(:new).with(user).and_call_original
- expect(result.issuable_params[:include_subgroups]).to eq(true)
+ subject
+ end
+ end
+
+ describe "#issuable_params" do
+ it 'sets include_subgroups flag by default' do
+ expect(results.issuable_params[:include_subgroups]).to eq(true)
end
end
end
diff --git a/spec/lib/gitlab/hashed_storage/migrator_spec.rb b/spec/lib/gitlab/hashed_storage/migrator_spec.rb
index 0549b3128c7..f4f15cab05a 100644
--- a/spec/lib/gitlab/hashed_storage/migrator_spec.rb
+++ b/spec/lib/gitlab/hashed_storage/migrator_spec.rb
@@ -232,4 +232,16 @@ RSpec.describe Gitlab::HashedStorage::Migrator, :redis do
expect(subject.rollback_pending?).to be_falsey
end
end
+
+ describe 'abort_rollback!' do
+ let_it_be(:project) { create(:project, :empty_repo) }
+
+ it 'removes any rollback related scheduled job' do
+ Sidekiq::Testing.disable! do
+ ::HashedStorage::RollbackerWorker.perform_async(1, 5)
+
+ expect { subject.abort_rollback! }.to change { subject.rollback_pending? }.from(true).to(false)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/http_spec.rb b/spec/lib/gitlab/http_spec.rb
index 5c990eb3248..308f7f46251 100644
--- a/spec/lib/gitlab/http_spec.rb
+++ b/spec/lib/gitlab/http_spec.rb
@@ -157,17 +157,6 @@ RSpec.describe Gitlab::HTTP do
described_class.put('http://example.org', write_timeout: 1)
end
end
-
- context 'when default timeouts feature is disabled' do
- it 'does not apply any defaults' do
- stub_feature_flags(http_default_timeouts: false)
- expect(described_class).to receive(:httparty_perform_request).with(
- Net::HTTP::Get, 'http://example.org', open_timeout: 1
- ).and_call_original
-
- described_class.get('http://example.org', open_timeout: 1)
- end
- end
end
describe '.try_get' do
diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb
index cfa39d95ebd..9165ccfb1ef 100644
--- a/spec/lib/gitlab/i18n/po_linter_spec.rb
+++ b/spec/lib/gitlab/i18n/po_linter_spec.rb
@@ -140,7 +140,7 @@ RSpec.describe Gitlab::I18n::PoLinter do
let(:po_path) { 'spec/fixtures/unescaped_chars.po' }
it 'contains an error' do
- message_id = 'You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?'
+ message_id = 'You are going to transfer %{project_name_with_namespace} to another namespace. Are you ABSOLUTELY sure?'
expected_error = 'translation contains unescaped `%`, escape it using `%%`'
expect(errors[message_id]).to include(expected_error)
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 37b5d8a1021..3126d87a0d6 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -15,6 +15,7 @@ issues:
- resource_iteration_events
- sent_notifications
- sentry_issue
+- issuable_severity
- label_links
- labels
- last_edited_by
@@ -28,6 +29,7 @@ issues:
- merge_requests_closing_issues
- metrics
- timelogs
+- issuable_severity
- issue_assignees
- closed_by
- epic_issue
@@ -120,6 +122,7 @@ merge_requests:
- award_emoji
- author
- assignee
+- reviewers
- updated_by
- milestone
- iteration
@@ -127,6 +130,7 @@ merge_requests:
- resource_label_events
- resource_milestone_events
- resource_state_events
+- resource_iteration_events
- label_links
- labels
- last_edited_by
@@ -147,6 +151,7 @@ merge_requests:
- latest_merge_request_diff
- pipelines_for_merge_request
- merge_request_assignees
+- merge_request_reviewers
- suggestions
- diff_note_positions
- unresolved_notes
@@ -175,9 +180,12 @@ external_pull_requests:
merge_request_diff:
- merge_request
- merge_request_diff_commits
+- merge_request_diff_detail
- merge_request_diff_files
merge_request_diff_commits:
- merge_request_diff
+merge_request_diff_detail:
+- merge_request_diff
merge_request_diff_files:
- merge_request_diff
merge_request_context_commits:
@@ -291,6 +299,7 @@ protected_branches:
- merge_access_levels
- push_access_levels
- unprotect_access_levels
+- approval_project_rules
protected_tags:
- project
- create_access_levels
@@ -357,6 +366,7 @@ project:
- youtrack_service
- custom_issue_tracker_service
- bugzilla_service
+- ewm_service
- external_wiki_service
- mock_ci_service
- mock_deployment_service
@@ -409,6 +419,8 @@ project:
- project_feature
- auto_devops
- pages_domains
+- pages_metadatum
+- pages_deployments
- authorized_users
- project_authorizations
- remote_mirrors
@@ -455,7 +467,6 @@ project:
- approval_merge_request_rules
- approvers
- approver_users
-- pages_domains
- audit_events
- path_locks
- approver_groups
@@ -472,6 +483,8 @@ project:
- dast_site_profiles
- dast_scanner_profiles
- dast_sites
+- dast_site_tokens
+- dast_site_validations
- operations_feature_flags
- operations_feature_flags_client
- operations_feature_flags_user_lists
@@ -493,7 +506,6 @@ project:
- designs
- project_aliases
- external_pull_requests
-- pages_metadatum
- alerts_service
- grafana_integration
- remove_source_branch_after_merge
@@ -536,8 +548,11 @@ timelogs:
- issue
- merge_request
- user
+- note
push_event_payload:
- event
+issuable_severity:
+- issue
issue_assignees:
- issue
- assignee
@@ -613,6 +628,7 @@ boards:
- assignee
- labels
- user_preferences
+- boards_epic_user_preferences
lists:
- user
- milestone
@@ -646,6 +662,8 @@ zoom_meetings:
- issue
sentry_issue:
- issue
+issuable_severity:
+- issue
design_versions: *version
epic:
- subscriptions
@@ -673,8 +691,10 @@ epic:
- due_date_sourcing_epic
- events
- resource_label_events
+- resource_state_events
- user_mentions
- note_authors
+- boards_epic_user_preferences
epic_issue:
- epic
- issue
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 5b6be0b3198..93b6f93f0ec 100644
--- a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
+++ b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
@@ -133,12 +133,6 @@ RSpec.describe Gitlab::ImportExport::FastHashSerializer do
expect(builds_count).to eq(1)
end
- it 'has no when YML attributes but only the DB column' do
- expect_any_instance_of(Gitlab::Ci::YamlProcessor).not_to receive(:build_attributes)
-
- subject
- end
-
it 'has pipeline commits' do
expect(subject['ci_pipelines']).not_to be_empty
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 a2c5848f100..ece261e0882 100644
--- a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
@@ -381,12 +381,6 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver do
expect(project_tree_saver.save).to be true
end
-
- it 'has no when YML attributes but only the DB column' do
- expect_any_instance_of(Gitlab::Ci::YamlProcessor).not_to receive(:build_attributes)
-
- project_tree_saver.save
- end
end
end
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index a108bc94da5..5ca7c5b7a91 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -206,6 +206,7 @@ MergeRequest:
- head_pipeline_id
- discussion_locked
- allow_maintainer_to_push
+- merge_ref_sha
MergeRequestDiff:
- id
- state
diff --git a/spec/lib/gitlab/incident_management/pager_duty/incident_issue_description_spec.rb b/spec/lib/gitlab/incident_management/pager_duty/incident_issue_description_spec.rb
index 6dc96217f09..535cce6aa04 100644
--- a/spec/lib/gitlab/incident_management/pager_duty/incident_issue_description_spec.rb
+++ b/spec/lib/gitlab/incident_management/pager_duty/incident_issue_description_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'timecop'
RSpec.describe Gitlab::IncidentManagement::PagerDuty::IncidentIssueDescription do
describe '#to_s' do
@@ -50,7 +49,7 @@ RSpec.describe Gitlab::IncidentManagement::PagerDuty::IncidentIssueDescription d
let(:created_at) { nil }
it 'description contains current time in UTC' do
- Timecop.freeze do
+ freeze_time do
now = Time.current.utc.strftime('%d %B %Y, %-l:%M%p (%Z)')
expect(to_s).to include(
diff --git a/spec/lib/gitlab/jira/dvcs_spec.rb b/spec/lib/gitlab/jira/dvcs_spec.rb
new file mode 100644
index 00000000000..09e777b38ea
--- /dev/null
+++ b/spec/lib/gitlab/jira/dvcs_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Jira::Dvcs do
+ describe '.encode_slash' do
+ it 'replaces slash character' do
+ expect(described_class.encode_slash('a/b/c')).to eq('a@b@c')
+ end
+
+ it 'ignores path without slash' do
+ expect(described_class.encode_slash('foo')).to eq('foo')
+ end
+ end
+
+ describe '.decode_slash' do
+ it 'replaces slash character' do
+ expect(described_class.decode_slash('a@b@c')).to eq('a/b/c')
+ end
+
+ it 'ignores path without slash' do
+ expect(described_class.decode_slash('foo')).to eq('foo')
+ end
+ end
+
+ describe '.encode_project_name' do
+ let(:group) { create(:group)}
+ let(:project) { create(:project, group: group)}
+
+ context 'root group' do
+ it 'returns project path' do
+ expect(described_class.encode_project_name(project)).to eq(project.path)
+ end
+ end
+
+ context 'nested group' do
+ let(:group) { create(:group, :nested)}
+
+ it 'returns encoded project full path' do
+ expect(described_class.encode_project_name(project)).to eq(described_class.encode_slash(project.full_path))
+ end
+ end
+ end
+
+ describe '.restore_full_path' do
+ context 'project name is an encoded full path' do
+ it 'returns decoded project path' do
+ expect(described_class.restore_full_path(namespace: 'group1', project: 'group1@group2@project1')).to eq('group1/group2/project1')
+ end
+ end
+
+ context 'project name is not an encoded full path' do
+ it 'assumes project belongs to root namespace and returns full project path based on passed in namespace' do
+ expect(described_class.restore_full_path(namespace: 'group1', project: 'project1')).to eq('group1/project1')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/jira/middleware_spec.rb b/spec/lib/gitlab/jira/middleware_spec.rb
new file mode 100644
index 00000000000..1fe22b145a6
--- /dev/null
+++ b/spec/lib/gitlab/jira/middleware_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Jira::Middleware do
+ let(:app) { double(:app) }
+ let(:middleware) { described_class.new(app) }
+ let(:jira_user_agent) { 'Jira DVCS Connector Vertigo/5.0.0-D20170810T012915' }
+
+ describe '.jira_dvcs_connector?' do
+ it 'returns true when DVCS connector' do
+ expect(described_class.jira_dvcs_connector?('HTTP_USER_AGENT' => jira_user_agent)).to eq(true)
+ end
+
+ it 'returns true if user agent starts with "Jira DVCS Connector"' do
+ expect(described_class.jira_dvcs_connector?('HTTP_USER_AGENT' => 'Jira DVCS Connector')).to eq(true)
+ end
+
+ it 'returns false when not DVCS connector' do
+ expect(described_class.jira_dvcs_connector?('HTTP_USER_AGENT' => 'pokemon')).to eq(false)
+ end
+ end
+
+ describe '#call' do
+ it 'adjusts HTTP_AUTHORIZATION env when request from Jira DVCS user agent' do
+ expect(app).to receive(:call).with('HTTP_USER_AGENT' => jira_user_agent,
+ 'HTTP_AUTHORIZATION' => 'Bearer hash-123')
+
+ middleware.call('HTTP_USER_AGENT' => jira_user_agent, 'HTTP_AUTHORIZATION' => 'token hash-123')
+ end
+
+ it 'does not change HTTP_AUTHORIZATION env when request is not from Jira DVCS user agent' do
+ env = { 'HTTP_USER_AGENT' => 'Mozilla/5.0', 'HTTP_AUTHORIZATION' => 'token hash-123' }
+
+ expect(app).to receive(:call).with(env)
+
+ middleware.call(env)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kas_spec.rb b/spec/lib/gitlab/kas_spec.rb
new file mode 100644
index 00000000000..ce22f36e9fd
--- /dev/null
+++ b/spec/lib/gitlab/kas_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Kas do
+ let(:jwt_secret) { SecureRandom.random_bytes(described_class::SECRET_LENGTH) }
+
+ before do
+ allow(described_class).to receive(:secret).and_return(jwt_secret)
+ end
+
+ describe '.verify_api_request' do
+ let(:payload) { { 'iss' => described_class::JWT_ISSUER } }
+
+ it 'returns nil if fails to validate the JWT' do
+ encoded_token = JWT.encode(payload, 'wrongsecret', 'HS256')
+ headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
+
+ expect(described_class.verify_api_request(headers)).to be_nil
+ end
+
+ it 'returns the decoded JWT' do
+ encoded_token = JWT.encode(payload, described_class.secret, 'HS256')
+ headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
+
+ expect(described_class.verify_api_request(headers)).to eq([{ "iss" => described_class::JWT_ISSUER }, { "alg" => "HS256" }])
+ end
+ end
+
+ describe '.secret_path' do
+ it 'returns default gitlab config' do
+ expect(described_class.secret_path).to eq(Gitlab.config.gitlab_kas.secret_file)
+ end
+ end
+
+ describe '.ensure_secret!' do
+ context 'secret file exists' do
+ before do
+ allow(File).to receive(:exist?).with(Gitlab.config.gitlab_kas.secret_file).and_return(true)
+ end
+
+ it 'does not call write_secret' do
+ expect(described_class).not_to receive(:write_secret)
+
+ described_class.ensure_secret!
+ end
+ end
+
+ context 'secret file does not exist' do
+ before do
+ allow(File).to receive(:exist?).with(Gitlab.config.gitlab_kas.secret_file).and_return(false)
+ end
+
+ it 'calls write_secret' do
+ expect(described_class).to receive(:write_secret)
+
+ described_class.ensure_secret!
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/cilium_network_policy_spec.rb b/spec/lib/gitlab/kubernetes/cilium_network_policy_spec.rb
index 9600a70a95d..3f5661d4ca6 100644
--- a/spec/lib/gitlab/kubernetes/cilium_network_policy_spec.rb
+++ b/spec/lib/gitlab/kubernetes/cilium_network_policy_spec.rb
@@ -7,23 +7,27 @@ RSpec.describe Gitlab::Kubernetes::CiliumNetworkPolicy do
described_class.new(
name: name,
namespace: namespace,
- creation_timestamp: '2020-04-14T00:08:30Z',
- endpoint_selector: endpoint_selector,
+ description: description,
+ selector: selector,
ingress: ingress,
egress: egress,
- description: description
+ labels: labels,
+ resource_version: resource_version
)
end
let(:resource) do
::Kubeclient::Resource.new(
- kind: partial_class_name,
- apiVersion: "cilium.io/v2",
+ apiVersion: Gitlab::Kubernetes::CiliumNetworkPolicy::API_VERSION,
+ kind: Gitlab::Kubernetes::CiliumNetworkPolicy::KIND,
metadata: { name: name, namespace: namespace, resourceVersion: resource_version },
- spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: nil }
+ spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: egress },
+ description: description
)
end
+ let(:selector) { endpoint_selector }
+ let(:labels) { nil }
let(:name) { 'example-name' }
let(:namespace) { 'example-namespace' }
let(:endpoint_selector) { { matchLabels: { role: 'db' } } }
@@ -48,34 +52,14 @@ RSpec.describe Gitlab::Kubernetes::CiliumNetworkPolicy do
]
end
- include_examples 'network policy common specs' do
- let(:selector) { endpoint_selector}
- let(:policy) do
- described_class.new(
- name: name,
- namespace: namespace,
- selector: selector,
- ingress: ingress,
- labels: labels,
- resource_version: resource_version
- )
- end
-
- let(:spec) { { endpointSelector: selector, ingress: ingress, egress: nil } }
- let(:metadata) { { name: name, namespace: namespace, resourceVersion: resource_version } }
- end
-
- describe '#generate' do
- subject { policy.generate }
-
- it { is_expected.to eq(resource) }
- end
+ include_examples 'network policy common specs'
describe '.from_yaml' do
let(:manifest) do
<<~POLICY
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+ description: example-description
metadata:
name: example-name
namespace: example-namespace
@@ -88,6 +72,9 @@ RSpec.describe Gitlab::Kubernetes::CiliumNetworkPolicy do
- fromEndpoints:
- matchLabels:
project: myproject
+ egress:
+ - ports:
+ - port: 5978
POLICY
end
@@ -167,20 +154,22 @@ RSpec.describe Gitlab::Kubernetes::CiliumNetworkPolicy do
describe '.from_resource' do
let(:resource) do
::Kubeclient::Resource.new(
+ description: description,
metadata: {
name: name, namespace: namespace, creationTimestamp: '2020-04-14T00:08:30Z',
labels: { app: 'foo' }, resourceVersion: resource_version
},
- spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: nil, labels: nil, description: nil }
+ spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: nil, labels: nil }
)
end
let(:generated_resource) do
::Kubeclient::Resource.new(
- kind: partial_class_name,
- apiVersion: "cilium.io/v2",
+ apiVersion: Gitlab::Kubernetes::CiliumNetworkPolicy::API_VERSION,
+ kind: Gitlab::Kubernetes::CiliumNetworkPolicy::KIND,
+ description: description,
metadata: { name: name, namespace: namespace, resourceVersion: resource_version, labels: { app: 'foo' } },
- spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: nil }
+ spec: { endpointSelector: endpoint_selector, ingress: ingress }
)
end
@@ -197,7 +186,7 @@ RSpec.describe Gitlab::Kubernetes::CiliumNetworkPolicy do
context 'with resource without metadata' do
let(:resource) do
::Kubeclient::Resource.new(
- spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: nil, labels: nil, description: nil }
+ spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: nil, labels: nil }
)
end
@@ -214,4 +203,50 @@ RSpec.describe Gitlab::Kubernetes::CiliumNetworkPolicy do
it { is_expected.to be_nil }
end
end
+
+ describe '#resource' do
+ subject { policy.resource }
+
+ let(:resource) do
+ {
+ apiVersion: Gitlab::Kubernetes::CiliumNetworkPolicy::API_VERSION,
+ kind: Gitlab::Kubernetes::CiliumNetworkPolicy::KIND,
+ metadata: { name: name, namespace: namespace, resourceVersion: resource_version },
+ spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: egress },
+ description: description
+ }
+ end
+
+ it { is_expected.to eq(resource) }
+
+ context 'with labels' do
+ let(:labels) { { app: 'foo' } }
+
+ before do
+ resource[:metadata][:labels] = { app: 'foo' }
+ end
+
+ it { is_expected.to eq(resource) }
+ end
+
+ context 'without resource_version' do
+ let(:resource_version) { nil }
+
+ before do
+ resource[:metadata].delete(:resourceVersion)
+ end
+
+ it { is_expected.to eq(resource) }
+ end
+
+ context 'with nil egress' do
+ let(:egress) { nil }
+
+ before do
+ resource[:spec].delete(:egress)
+ end
+
+ it { is_expected.to eq(resource) }
+ end
+ end
end
diff --git a/spec/lib/gitlab/kubernetes/kube_client_spec.rb b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
index 8211b096d3b..90c11f29855 100644
--- a/spec/lib/gitlab/kubernetes/kube_client_spec.rb
+++ b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
@@ -376,6 +376,7 @@ RSpec.describe Gitlab::Kubernetes::KubeClient do
[
:create_network_policy,
:get_network_policies,
+ :get_network_policy,
:update_network_policy,
:delete_network_policy
].each do |method|
@@ -400,6 +401,7 @@ RSpec.describe Gitlab::Kubernetes::KubeClient do
[
:create_cilium_network_policy,
:get_cilium_network_policies,
+ :get_cilium_network_policy,
:update_cilium_network_policy,
:delete_cilium_network_policy
].each do |method|
diff --git a/spec/lib/gitlab/kubernetes/network_policy_spec.rb b/spec/lib/gitlab/kubernetes/network_policy_spec.rb
index 5d1dd5dec59..d3640c61d94 100644
--- a/spec/lib/gitlab/kubernetes/network_policy_spec.rb
+++ b/spec/lib/gitlab/kubernetes/network_policy_spec.rb
@@ -7,21 +7,22 @@ RSpec.describe Gitlab::Kubernetes::NetworkPolicy do
described_class.new(
name: name,
namespace: namespace,
- creation_timestamp: '2020-04-14T00:08:30Z',
- selector: pod_selector,
- policy_types: %w(Ingress Egress),
+ selector: selector,
ingress: ingress,
- egress: egress
+ labels: labels
)
end
let(:resource) do
::Kubeclient::Resource.new(
+ kind: Gitlab::Kubernetes::NetworkPolicy::KIND,
metadata: { name: name, namespace: namespace },
spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
)
end
+ let(:selector) { pod_selector }
+ let(:labels) { nil }
let(:name) { 'example-name' }
let(:namespace) { 'example-namespace' }
let(:pod_selector) { { matchLabels: { role: 'db' } } }
@@ -44,27 +45,7 @@ RSpec.describe Gitlab::Kubernetes::NetworkPolicy do
]
end
- include_examples 'network policy common specs' do
- let(:selector) { pod_selector }
- let(:policy) do
- described_class.new(
- name: name,
- namespace: namespace,
- selector: selector,
- ingress: ingress,
- labels: labels
- )
- end
-
- let(:spec) { { podSelector: selector, policyTypes: ["Ingress"], ingress: ingress, egress: nil } }
- let(:metadata) { { name: name, namespace: namespace } }
- end
-
- describe '#generate' do
- subject { policy.generate }
-
- it { is_expected.to eq(resource) }
- end
+ include_examples 'network policy common specs'
describe '.from_yaml' do
let(:manifest) do
@@ -180,6 +161,7 @@ RSpec.describe Gitlab::Kubernetes::NetworkPolicy do
let(:generated_resource) do
::Kubeclient::Resource.new(
+ kind: Gitlab::Kubernetes::NetworkPolicy::KIND,
metadata: { name: name, namespace: namespace, labels: { app: 'foo' } },
spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
)
@@ -215,4 +197,31 @@ RSpec.describe Gitlab::Kubernetes::NetworkPolicy do
it { is_expected.to be_nil }
end
end
+
+ describe '#resource' do
+ subject { policy.resource }
+
+ let(:resource) do
+ {
+ kind: Gitlab::Kubernetes::NetworkPolicy::KIND,
+ metadata: { name: name, namespace: namespace },
+ spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
+ }
+ end
+
+ it { is_expected.to eq(resource) }
+
+ context 'with labels' do
+ let(:labels) { { app: 'foo' } }
+ let(:resource) do
+ {
+ kind: Gitlab::Kubernetes::NetworkPolicy::KIND,
+ metadata: { name: name, namespace: namespace, labels: { app: 'foo' } },
+ spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
+ }
+ end
+
+ it { is_expected.to eq(resource) }
+ end
+ end
end
diff --git a/spec/lib/gitlab/lfs/client_spec.rb b/spec/lib/gitlab/lfs/client_spec.rb
new file mode 100644
index 00000000000..03563a632d6
--- /dev/null
+++ b/spec/lib/gitlab/lfs/client_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Lfs::Client do
+ let(:base_url) { "https://example.com" }
+ let(:username) { 'user' }
+ let(:password) { 'password' }
+ let(:credentials) { { user: username, password: password, auth_method: 'password' } }
+
+ let(:basic_auth_headers) do
+ { 'Authorization' => "Basic #{Base64.strict_encode64("#{username}:#{password}")}" }
+ end
+
+ let(:upload_action) do
+ {
+ "href" => "#{base_url}/some/file",
+ "header" => {
+ "Key" => "value"
+ }
+ }
+ end
+
+ subject(:lfs_client) { described_class.new(base_url, credentials: credentials) }
+
+ describe '#batch' do
+ let_it_be(:objects) { create_list(:lfs_object, 3) }
+
+ context 'server returns 200 OK' do
+ it 'makes a successful batch request' do
+ stub = stub_batch(
+ objects: objects,
+ headers: basic_auth_headers
+ ).to_return(
+ status: 200,
+ body: { 'objects' => 'anything', 'transfer' => 'basic' }.to_json,
+ headers: { 'Content-Type' => 'application/vnd.git-lfs+json' }
+ )
+
+ result = lfs_client.batch('upload', objects)
+
+ expect(stub).to have_been_requested
+ expect(result).to eq('objects' => 'anything', 'transfer' => 'basic')
+ end
+ end
+
+ context 'server returns 400 error' do
+ it 'raises an error' do
+ stub_batch(objects: objects, headers: basic_auth_headers).to_return(status: 400)
+
+ expect { lfs_client.batch('upload', objects) }.to raise_error(/Failed/)
+ end
+ end
+
+ context 'server returns 500 error' do
+ it 'raises an error' do
+ stub_batch(objects: objects, headers: basic_auth_headers).to_return(status: 400)
+
+ expect { lfs_client.batch('upload', objects) }.to raise_error(/Failed/)
+ end
+ end
+
+ context 'server returns an exotic transfer method' do
+ it 'raises an error' do
+ stub_batch(
+ objects: objects,
+ headers: basic_auth_headers
+ ).to_return(
+ status: 200,
+ body: { 'transfer' => 'carrier-pigeon' }.to_json,
+ headers: { 'Content-Type' => 'application/vnd.git-lfs+json' }
+ )
+
+ expect { lfs_client.batch('upload', objects) }.to raise_error(/Unsupported transfer/)
+ end
+ end
+
+ def stub_batch(objects:, headers:, operation: 'upload', transfer: 'basic')
+ objects = objects.map { |o| { oid: o.oid, size: o.size } }
+ body = { operation: operation, 'transfers': [transfer], objects: objects }.to_json
+
+ stub_request(:post, base_url + '/info/lfs/objects/batch').with(body: body, headers: headers)
+ end
+ end
+
+ describe "#upload" do
+ let_it_be(:object) { create(:lfs_object) }
+
+ context 'server returns 200 OK to an authenticated request' do
+ it "makes an HTTP PUT with expected parameters" do
+ stub_upload(object: object, headers: upload_action['header']).to_return(status: 200)
+
+ lfs_client.upload(object, upload_action, authenticated: true)
+ end
+ end
+
+ context 'server returns 200 OK to an unauthenticated request' do
+ it "makes an HTTP PUT with expected parameters" do
+ stub = stub_upload(
+ object: object,
+ headers: basic_auth_headers.merge(upload_action['header'])
+ ).to_return(status: 200)
+
+ lfs_client.upload(object, upload_action, authenticated: false)
+
+ expect(stub).to have_been_requested
+ end
+ end
+
+ context 'LFS object has no file' do
+ let(:object) { LfsObject.new }
+
+ it 'makes an HJTT PUT with expected parameters' do
+ stub = stub_upload(
+ object: object,
+ headers: upload_action['header']
+ ).to_return(status: 200)
+
+ lfs_client.upload(object, upload_action, authenticated: true)
+
+ expect(stub).to have_been_requested
+ end
+ end
+
+ context 'server returns 400 error' do
+ it 'raises an error' do
+ stub_upload(object: object, headers: upload_action['header']).to_return(status: 400)
+
+ expect { lfs_client.upload(object, upload_action, authenticated: true) }.to raise_error(/Failed/)
+ end
+ end
+
+ context 'server returns 500 error' do
+ it 'raises an error' do
+ stub_upload(object: object, headers: upload_action['header']).to_return(status: 500)
+
+ expect { lfs_client.upload(object, upload_action, authenticated: true) }.to raise_error(/Failed/)
+ end
+ end
+
+ def stub_upload(object:, headers:)
+ stub_request(:put, upload_action['href']).with(
+ body: object.file.read,
+ headers: headers.merge('Content-Length' => object.size.to_s)
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/log_timestamp_formatter_spec.rb b/spec/lib/gitlab/log_timestamp_formatter_spec.rb
index e06baa2324f..b51d0fec15e 100644
--- a/spec/lib/gitlab/log_timestamp_formatter_spec.rb
+++ b/spec/lib/gitlab/log_timestamp_formatter_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Gitlab::LogTimestampFormatter do
let(:formatted_timestamp) { Time.now.utc.iso8601(3) }
it 'logs the timestamp in UTC and ISO8601.3 format' do
- Timecop.freeze(Time.now) do
+ freeze_time do
expect(subject.call('', Time.now, '', '')).to include formatted_timestamp
end
end
diff --git a/spec/lib/gitlab/metrics/dashboard/importer_spec.rb b/spec/lib/gitlab/metrics/dashboard/importer_spec.rb
new file mode 100644
index 00000000000..8b705395a2c
--- /dev/null
+++ b/spec/lib/gitlab/metrics/dashboard/importer_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Metrics::Dashboard::Importer do
+ include MetricsDashboardHelpers
+
+ let_it_be(:dashboard_path) { '.gitlab/dashboards/sample_dashboard.yml' }
+ let_it_be(:project) { create(:project) }
+
+ before do
+ allow(subject).to receive(:dashboard_hash).and_return(dashboard_hash)
+ end
+
+ subject { described_class.new(dashboard_path, project) }
+
+ describe '.execute' do
+ context 'valid dashboard hash' do
+ let(:dashboard_hash) { load_sample_dashboard }
+
+ it 'imports metrics to database' do
+ expect { subject.execute }
+ .to change { PrometheusMetric.count }.from(0).to(3)
+ end
+ end
+
+ context 'invalid dashboard hash' do
+ let(:dashboard_hash) { {} }
+
+ it 'returns false' do
+ expect(subject.execute).to be(false)
+ end
+ end
+ end
+
+ describe '.execute!' do
+ context 'valid dashboard hash' do
+ let(:dashboard_hash) { load_sample_dashboard }
+
+ it 'imports metrics to database' do
+ expect { subject.execute }
+ .to change { PrometheusMetric.count }.from(0).to(3)
+ end
+ end
+
+ context 'invalid dashboard hash' do
+ let(:dashboard_hash) { {} }
+
+ it 'raises error' do
+ expect { subject.execute! }.to raise_error(Gitlab::Metrics::Dashboard::Validator::Errors::SchemaValidationError,
+ 'root is missing required keys: dashboard, panel_groups')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb b/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb
new file mode 100644
index 00000000000..09d5e048f6a
--- /dev/null
+++ b/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
+ include MetricsDashboardHelpers
+
+ describe '#execute' do
+ let(:project) { create(:project) }
+ let(:dashboard_path) { 'path/to/dashboard.yml' }
+
+ subject { described_class.new(dashboard_hash, project: project, dashboard_path: dashboard_path) }
+
+ context 'valid dashboard' do
+ let(:dashboard_hash) { load_sample_dashboard }
+
+ context 'with all new metrics' do
+ it 'creates PrometheusMetrics' do
+ expect { subject.execute }.to change { PrometheusMetric.count }.by(3)
+ end
+ end
+
+ context 'with existing metrics' do
+ let!(:existing_metric) do
+ create(:prometheus_metric, {
+ project: project,
+ identifier: 'metric_b',
+ title: 'overwrite',
+ y_label: 'overwrite',
+ query: 'overwrite',
+ unit: 'overwrite',
+ legend: 'overwrite'
+ })
+ end
+
+ it 'updates existing PrometheusMetrics' do
+ described_class.new(dashboard_hash, project: project, dashboard_path: dashboard_path).execute
+
+ expect(existing_metric.reload.attributes.with_indifferent_access).to include({
+ title: 'Super Chart B',
+ y_label: 'y_label',
+ query: 'query',
+ unit: 'unit',
+ legend: 'Legend Label'
+ })
+ end
+
+ it 'creates new PrometheusMetrics' do
+ expect { subject.execute }.to change { PrometheusMetric.count }.by(2)
+ end
+
+ context 'with stale metrics' do
+ let!(:stale_metric) do
+ create(:prometheus_metric,
+ project: project,
+ identifier: 'stale_metric',
+ dashboard_path: dashboard_path,
+ group: 3
+ )
+ end
+
+ it 'deletes stale metrics' do
+ subject.execute
+
+ expect { stale_metric.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+ end
+ end
+
+ context 'invalid dashboard' do
+ let(:dashboard_hash) { {} }
+
+ it 'returns false' do
+ expect(subject.execute).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/dashboard/stages/track_panel_type_spec.rb b/spec/lib/gitlab/metrics/dashboard/stages/track_panel_type_spec.rb
index d9987b67127..60010b9f257 100644
--- a/spec/lib/gitlab/metrics/dashboard/stages/track_panel_type_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/stages/track_panel_type_spec.rb
@@ -8,20 +8,19 @@ RSpec.describe Gitlab::Metrics::Dashboard::Stages::TrackPanelType do
let(:project) { build_stubbed(:project) }
let(:environment) { build_stubbed(:environment, project: project) }
- describe '#transform!' do
+ describe '#transform!', :snowplow do
subject { described_class.new(project, dashboard, environment: environment) }
let(:dashboard) { load_sample_dashboard.deep_symbolize_keys }
it 'creates tracking event' do
- stub_application_setting(snowplow_enabled: true, snowplow_collector_hostname: 'localhost')
- allow(Gitlab::Tracking).to receive(:event).and_call_original
-
subject.transform!
- expect(Gitlab::Tracking).to have_received(:event)
- .with('MetricsDashboard::Chart', 'chart_rendered', { label: 'area-chart' })
- .at_least(:once)
+ expect_snowplow_event(
+ category: 'MetricsDashboard::Chart',
+ action: 'chart_rendered',
+ label: 'area-chart'
+ )
end
end
end
diff --git a/spec/lib/gitlab/metrics/dashboard/transformers/yml/v1/prometheus_metrics_spec.rb b/spec/lib/gitlab/metrics/dashboard/transformers/yml/v1/prometheus_metrics_spec.rb
new file mode 100644
index 00000000000..3af8b51c889
--- /dev/null
+++ b/spec/lib/gitlab/metrics/dashboard/transformers/yml/v1/prometheus_metrics_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Metrics::Dashboard::Transformers::Yml::V1::PrometheusMetrics do
+ include MetricsDashboardHelpers
+
+ describe '#execute' do
+ subject { described_class.new(dashboard_hash) }
+
+ context 'valid dashboard' do
+ let_it_be(:dashboard_hash) do
+ {
+ panel_groups: [{
+ panels: [
+ {
+ title: 'Panel 1 title',
+ y_label: 'Panel 1 y_label',
+ metrics: [
+ {
+ query_range: 'Panel 1 metric 1 query_range',
+ unit: 'Panel 1 metric 1 unit',
+ label: 'Panel 1 metric 1 label',
+ id: 'Panel 1 metric 1 id'
+ },
+ {
+ query: 'Panel 1 metric 2 query',
+ unit: 'Panel 1 metric 2 unit',
+ label: 'Panel 1 metric 2 label',
+ id: 'Panel 1 metric 2 id'
+ }
+ ]
+ },
+ {
+ title: 'Panel 2 title',
+ y_label: 'Panel 2 y_label',
+ metrics: [{
+ query_range: 'Panel 2 metric 1 query_range',
+ unit: 'Panel 2 metric 1 unit',
+ label: 'Panel 2 metric 1 label',
+ id: 'Panel 2 metric 1 id'
+ }]
+ }
+ ]
+ }]
+ }
+ end
+
+ let(:expected_metrics) do
+ [
+ {
+ title: 'Panel 1 title',
+ y_label: 'Panel 1 y_label',
+ query: "Panel 1 metric 1 query_range",
+ unit: 'Panel 1 metric 1 unit',
+ legend: 'Panel 1 metric 1 label',
+ identifier: 'Panel 1 metric 1 id',
+ group: 3,
+ common: false
+ },
+ {
+ title: 'Panel 1 title',
+ y_label: 'Panel 1 y_label',
+ query: 'Panel 1 metric 2 query',
+ unit: 'Panel 1 metric 2 unit',
+ legend: 'Panel 1 metric 2 label',
+ identifier: 'Panel 1 metric 2 id',
+ group: 3,
+ common: false
+ },
+ {
+ title: 'Panel 2 title',
+ y_label: 'Panel 2 y_label',
+ query: 'Panel 2 metric 1 query_range',
+ unit: 'Panel 2 metric 1 unit',
+ legend: 'Panel 2 metric 1 label',
+ identifier: 'Panel 2 metric 1 id',
+ group: 3,
+ common: false
+ }
+ ]
+ end
+
+ it 'returns collection of metrics with correct attributes' do
+ expect(subject.execute).to match_array(expected_metrics)
+ end
+ end
+
+ context 'invalid dashboard' do
+ let(:dashboard_hash) { {} }
+
+ it 'raises missing attribute error' do
+ expect { subject.execute }.to raise_error(
+ ::Gitlab::Metrics::Dashboard::Transformers::Errors::MissingAttribute, "Missing attribute: 'panel_groups'"
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/dashboard/url_spec.rb b/spec/lib/gitlab/metrics/dashboard/url_spec.rb
index 205e1000376..830d43169a9 100644
--- a/spec/lib/gitlab/metrics/dashboard/url_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/url_spec.rb
@@ -6,11 +6,12 @@ RSpec.describe Gitlab::Metrics::Dashboard::Url do
include Gitlab::Routing.url_helpers
describe '#metrics_regex' do
+ let(:environment_id) { 1 }
let(:url_params) do
[
'foo',
'bar',
- 1,
+ environment_id,
{
start: '2019-08-02T05:43:09.000Z',
dashboard: 'config/prometheus/common_metrics.yml',
@@ -33,12 +34,42 @@ RSpec.describe Gitlab::Metrics::Dashboard::Url do
subject { described_class.metrics_regex }
- context 'for metrics route' do
+ context 'for /-/environments/:environment_id/metrics route' do
let(:url) { metrics_namespace_project_environment_url(*url_params) }
it_behaves_like 'regex which matches url when expected'
end
+ context 'for /-/metrics?environment=:environment_id route' do
+ let(:url) { namespace_project_metrics_dashboard_url(*url_params) }
+ let(:url_params) do
+ [
+ 'namespace1',
+ 'project1',
+ {
+ environment: environment_id,
+ start: '2019-08-02T05:43:09.000Z',
+ dashboard: 'config/prometheus/common_metrics.yml',
+ group: 'awesome group',
+ anchor: 'title'
+ }
+ ]
+ end
+
+ let(:expected_params) do
+ {
+ 'url' => url,
+ 'namespace' => 'namespace1',
+ 'project' => 'project1',
+ 'environment' => "#{environment_id}",
+ 'query' => "?dashboard=config%2Fprometheus%2Fcommon_metrics.yml&environment=#{environment_id}&group=awesome+group&start=2019-08-02T05%3A43%3A09.000Z",
+ 'anchor' => '#title'
+ }
+ end
+
+ it_behaves_like 'regex which matches url when expected'
+ end
+
context 'for metrics_dashboard route' do
let(:url) { metrics_dashboard_namespace_project_environment_url(*url_params) }
@@ -47,16 +78,19 @@ RSpec.describe Gitlab::Metrics::Dashboard::Url do
end
describe '#clusters_regex' do
- let(:url) do
- Gitlab::Routing.url_helpers.namespace_project_cluster_url(
+ let(:url) { Gitlab::Routing.url_helpers.namespace_project_cluster_url(*url_params) }
+ let(:url_params) do
+ [
'foo',
'bar',
'1',
- group: 'Cluster Health',
- title: 'Memory Usage',
- y_label: 'Memory 20(GiB)',
- anchor: 'title'
- )
+ {
+ group: 'Cluster Health',
+ title: 'Memory Usage',
+ y_label: 'Memory 20(GiB)',
+ anchor: 'title'
+ }
+ ]
end
let(:expected_params) do
@@ -73,6 +107,27 @@ RSpec.describe Gitlab::Metrics::Dashboard::Url do
subject { described_class.clusters_regex }
it_behaves_like 'regex which matches url when expected'
+
+ context 'for metrics_dashboard route' do
+ let(:url) do
+ metrics_dashboard_namespace_project_cluster_url(
+ *url_params, cluster_type: :project, embedded: true, format: :json
+ )
+ end
+
+ let(:expected_params) do
+ {
+ 'url' => url,
+ 'namespace' => 'foo',
+ 'project' => 'bar',
+ 'cluster_id' => '1',
+ 'query' => '?cluster_type=project&embedded=true',
+ 'anchor' => nil
+ }
+ end
+
+ it_behaves_like 'regex which matches url when expected'
+ end
end
describe '#grafana_regex' do
@@ -103,15 +158,18 @@ RSpec.describe Gitlab::Metrics::Dashboard::Url do
end
describe '#alert_regex' do
- let(:url) do
- Gitlab::Routing.url_helpers.metrics_dashboard_namespace_project_prometheus_alert_url(
+ let(:url) { Gitlab::Routing.url_helpers.metrics_dashboard_namespace_project_prometheus_alert_url(*url_params) }
+ let(:url_params) do
+ [
'foo',
'bar',
'1',
- start: '2020-02-10T12:59:49.938Z',
- end: '2020-02-10T20:59:49.938Z',
- anchor: "anchor"
- )
+ {
+ start: '2020-02-10T12:59:49.938Z',
+ end: '2020-02-10T20:59:49.938Z',
+ anchor: "anchor"
+ }
+ ]
end
let(:expected_params) do
@@ -128,6 +186,21 @@ RSpec.describe Gitlab::Metrics::Dashboard::Url do
subject { described_class.alert_regex }
it_behaves_like 'regex which matches url when expected'
+
+ it_behaves_like 'regex which matches url when expected' do
+ let(:url) { Gitlab::Routing.url_helpers.metrics_dashboard_namespace_project_prometheus_alert_url(*url_params, format: :json) }
+
+ let(:expected_params) do
+ {
+ 'url' => url,
+ 'namespace' => 'foo',
+ 'project' => 'bar',
+ 'alert' => '1',
+ 'query' => nil,
+ 'anchor' => nil
+ }
+ end
+ end
end
describe '#build_dashboard_url' do
diff --git a/spec/lib/gitlab/metrics/dashboard/validator/errors_spec.rb b/spec/lib/gitlab/metrics/dashboard/validator/errors_spec.rb
index f0db1bd0d33..fdbba6c31b5 100644
--- a/spec/lib/gitlab/metrics/dashboard/validator/errors_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/validator/errors_spec.rb
@@ -34,6 +34,17 @@ RSpec.describe Gitlab::Metrics::Dashboard::Validator::Errors do
it { is_expected.to eq 'root is missing required keys: one' }
end
+
+ context 'when there is type mismatch' do
+ %w(null string boolean integer number array object).each do |expected_type|
+ context "on type: #{expected_type}" do
+ let(:type) { expected_type }
+ let(:details) { nil }
+
+ it { is_expected.to eq "'property_name' at root is not of type: #{expected_type}" }
+ end
+ end
+ end
end
context 'for nested object' do
@@ -52,8 +63,6 @@ RSpec.describe Gitlab::Metrics::Dashboard::Validator::Errors do
let(:type) { expected_type }
let(:details) { nil }
- subject { described_class.new(error_hash).message }
-
it { is_expected.to eq "'property_name' at /nested_objects/0 is not of type: #{expected_type}" }
end
end
diff --git a/spec/lib/gitlab/metrics/dashboard/validator_spec.rb b/spec/lib/gitlab/metrics/dashboard/validator_spec.rb
index c4cda271408..eb67ea2b7da 100644
--- a/spec/lib/gitlab/metrics/dashboard/validator_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/validator_spec.rb
@@ -143,4 +143,56 @@ RSpec.describe Gitlab::Metrics::Dashboard::Validator do
end
end
end
+
+ describe '#errors' do
+ context 'valid dashboard schema' do
+ it 'returns no errors' do
+ expect(described_class.errors(valid_dashboard)).to eq []
+ end
+
+ context 'with duplicate metric_ids' do
+ it 'returns errors' do
+ expect(described_class.errors(duplicate_id_dashboard)).to eq [Gitlab::Metrics::Dashboard::Validator::Errors::DuplicateMetricIds.new]
+ end
+ end
+
+ context 'with dashboard_path and project' do
+ subject { described_class.errors(valid_dashboard, dashboard_path: 'test/path.yml', project: project) }
+
+ context 'with no conflicting metric identifiers in db' do
+ it { is_expected.to eq [] }
+ end
+
+ context 'with metric identifier present in current dashboard' do
+ before do
+ create(:prometheus_metric,
+ identifier: 'metric_a1',
+ dashboard_path: 'test/path.yml',
+ project: project
+ )
+ end
+
+ it { is_expected.to eq [] }
+ end
+
+ context 'with metric identifier present in another dashboard' do
+ before do
+ create(:prometheus_metric,
+ identifier: 'metric_a1',
+ dashboard_path: 'some/other/dashboard/path.yml',
+ project: project
+ )
+ end
+
+ it { is_expected.to eq [Gitlab::Metrics::Dashboard::Validator::Errors::DuplicateMetricIds.new] }
+ end
+ end
+ end
+
+ context 'invalid dashboard schema' do
+ it 'returns collection of validation errors' do
+ expect(described_class.errors(invalid_dashboard)).to all be_kind_of(Gitlab::Metrics::Dashboard::Validator::Errors::SchemaValidationError)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb b/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb
index 2c5ef09f799..01cf47a7c58 100644
--- a/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb
+++ b/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb
@@ -15,6 +15,7 @@ RSpec.describe Gitlab::Metrics::Exporter::SidekiqExporter do
monitoring: {
sidekiq_exporter: {
enabled: true,
+ log_enabled: false,
port: 0,
address: '127.0.0.1'
}
@@ -25,6 +26,29 @@ RSpec.describe Gitlab::Metrics::Exporter::SidekiqExporter do
it 'does start thread' do
expect(exporter.start).not_to be_nil
end
+
+ it 'does not enable logging by default' do
+ expect(exporter.log_filename).to eq(File::NULL)
+ end
+ end
+
+ context 'with logging enabled' do
+ before do
+ stub_config(
+ monitoring: {
+ sidekiq_exporter: {
+ enabled: true,
+ log_enabled: true,
+ port: 0,
+ address: '127.0.0.1'
+ }
+ }
+ )
+ end
+
+ it 'returns a valid log filename' do
+ expect(exporter.log_filename).to end_with('sidekiq_exporter.log')
+ end
end
context 'when port is already taken' do
diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb
index 2729fbce974..b15e06a0861 100644
--- a/spec/lib/gitlab/metrics/instrumentation_spec.rb
+++ b/spec/lib/gitlab/metrics/instrumentation_spec.rb
@@ -12,6 +12,11 @@ RSpec.describe Gitlab::Metrics::Instrumentation do
text
end
+ def self.wat(text = 'wat')
+ text
+ end
+ private_class_method :wat
+
class << self
def buzz(text = 'buzz')
text
@@ -242,6 +247,7 @@ RSpec.describe Gitlab::Metrics::Instrumentation do
expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true)
expect(@dummy.method(:foo).source_location.first).to match(/instrumentation\.rb/)
+ expect(@dummy.public_methods).to include(:foo)
end
it 'instruments all protected class methods' do
@@ -249,13 +255,16 @@ RSpec.describe Gitlab::Metrics::Instrumentation do
expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true)
expect(@dummy.method(:flaky).source_location.first).to match(/instrumentation\.rb/)
+ expect(@dummy.protected_methods).to include(:flaky)
end
- it 'instruments all private instance methods' do
+ it 'instruments all private class methods' do
described_class.instrument_methods(@dummy)
expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true)
expect(@dummy.method(:buzz).source_location.first).to match(/instrumentation\.rb/)
+ expect(@dummy.private_methods).to include(:buzz)
+ expect(@dummy.private_methods).to include(:wat)
end
it 'only instruments methods directly defined in the module' do
@@ -290,6 +299,7 @@ RSpec.describe Gitlab::Metrics::Instrumentation do
expect(described_class.instrumented?(@dummy)).to eq(true)
expect(@dummy.new.method(:bar).source_location.first).to match(/instrumentation\.rb/)
+ expect(@dummy.public_instance_methods).to include(:bar)
end
it 'instruments all protected instance methods' do
@@ -297,6 +307,7 @@ RSpec.describe Gitlab::Metrics::Instrumentation do
expect(described_class.instrumented?(@dummy)).to eq(true)
expect(@dummy.new.method(:chaf).source_location.first).to match(/instrumentation\.rb/)
+ expect(@dummy.protected_instance_methods).to include(:chaf)
end
it 'instruments all private instance methods' do
@@ -304,6 +315,7 @@ RSpec.describe Gitlab::Metrics::Instrumentation do
expect(described_class.instrumented?(@dummy)).to eq(true)
expect(@dummy.new.method(:wadus).source_location.first).to match(/instrumentation\.rb/)
+ expect(@dummy.private_instance_methods).to include(:wadus)
end
it 'only instruments methods directly defined in the module' do
diff --git a/spec/lib/gitlab/metrics/method_call_spec.rb b/spec/lib/gitlab/metrics/method_call_spec.rb
index 825c91b6cb4..fb5436a90e3 100644
--- a/spec/lib/gitlab/metrics/method_call_spec.rb
+++ b/spec/lib/gitlab/metrics/method_call_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe Gitlab::Metrics::MethodCall do
end
around do |example|
- Timecop.freeze do
+ freeze_time do
example.run
end
end
diff --git a/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb
new file mode 100644
index 00000000000..7f05f35c941
--- /dev/null
+++ b/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Metrics::Samplers::ActionCableSampler do
+ let(:action_cable) { instance_double(ActionCable::Server::Base) }
+
+ subject { described_class.new(action_cable: action_cable) }
+
+ describe '#interval' do
+ it 'samples every five seconds by default' do
+ expect(subject.interval).to eq(5)
+ end
+
+ it 'samples at other intervals if requested' do
+ expect(described_class.new(11).interval).to eq(11)
+ end
+ end
+
+ describe '#sample' do
+ let(:pool) { instance_double(Concurrent::ThreadPoolExecutor) }
+
+ before do
+ allow(action_cable).to receive_message_chain(:worker_pool, :executor).and_return(pool)
+ allow(action_cable).to receive(:connections).and_return([])
+ allow(pool).to receive(:min_length).and_return(1)
+ allow(pool).to receive(:max_length).and_return(2)
+ allow(pool).to receive(:length).and_return(3)
+ allow(pool).to receive(:largest_length).and_return(4)
+ allow(pool).to receive(:completed_task_count).and_return(5)
+ allow(pool).to receive(:queue_length).and_return(6)
+ end
+
+ shared_examples 'collects metrics' do |expected_labels|
+ it 'includes active connections' do
+ expect(subject.metrics[:active_connections]).to receive(:set).with(expected_labels, 0)
+
+ subject.sample
+ end
+
+ it 'includes minimum worker pool size' do
+ expect(subject.metrics[:pool_min_size]).to receive(:set).with(expected_labels, 1)
+
+ subject.sample
+ end
+
+ it 'includes maximum worker pool size' do
+ expect(subject.metrics[:pool_max_size]).to receive(:set).with(expected_labels, 2)
+
+ subject.sample
+ end
+
+ it 'includes current worker pool size' do
+ expect(subject.metrics[:pool_current_size]).to receive(:set).with(expected_labels, 3)
+
+ subject.sample
+ end
+
+ it 'includes largest worker pool size' do
+ expect(subject.metrics[:pool_largest_size]).to receive(:set).with(expected_labels, 4)
+
+ subject.sample
+ end
+
+ it 'includes worker pool completed task count' do
+ expect(subject.metrics[:pool_completed_tasks]).to receive(:set).with(expected_labels, 5)
+
+ subject.sample
+ end
+
+ it 'includes worker pool pending task count' do
+ expect(subject.metrics[:pool_pending_tasks]).to receive(:set).with(expected_labels, 6)
+
+ subject.sample
+ end
+ end
+
+ context 'for in-app mode' do
+ before do
+ expect(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(true)
+ end
+
+ it_behaves_like 'collects metrics', server_mode: 'in-app'
+ end
+
+ context 'for standalone mode' do
+ before do
+ expect(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(false)
+ end
+
+ it_behaves_like 'collects metrics', server_mode: 'standalone'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
index 59a70ac74a5..eb6c83096b9 100644
--- a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
+++ b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Gitlab::Metrics::Samplers::RubySampler do
describe '#initialize' do
it 'sets process_start_time_seconds' do
- Timecop.freeze do
+ freeze_time do
expect(sampler.metrics[:process_start_time_seconds].get).to eq(Time.now.to_i)
end
end
diff --git a/spec/lib/gitlab/middleware/multipart/handler_for_jwt_params_spec.rb b/spec/lib/gitlab/middleware/multipart/handler_for_jwt_params_spec.rb
new file mode 100644
index 00000000000..59ec743f6ca
--- /dev/null
+++ b/spec/lib/gitlab/middleware/multipart/handler_for_jwt_params_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Middleware::Multipart::HandlerForJWTParams do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:env) { Rack::MockRequest.env_for('/', method: 'post', params: {}) }
+ let_it_be(:message) { { 'rewritten_fields' => {} } }
+
+ describe '#allowed_paths' do
+ let_it_be(:expected_allowed_paths) do
+ [
+ Dir.tmpdir,
+ ::FileUploader.root,
+ ::Gitlab.config.uploads.storage_path,
+ ::JobArtifactUploader.workhorse_upload_path,
+ ::LfsObjectUploader.workhorse_upload_path,
+ File.join(Rails.root, 'public/uploads/tmp')
+ ]
+ end
+
+ let_it_be(:expected_with_packages_path) { expected_allowed_paths + [::Packages::PackageFileUploader.workhorse_upload_path] }
+
+ subject { described_class.new(env, message).send(:allowed_paths) }
+
+ where(:package_features_enabled, :object_storage_enabled, :direct_upload_enabled, :expected_paths) do
+ false | false | true | :expected_allowed_paths
+ false | false | false | :expected_allowed_paths
+ false | true | true | :expected_allowed_paths
+ false | true | false | :expected_allowed_paths
+ true | false | true | :expected_with_packages_path
+ true | false | false | :expected_with_packages_path
+ true | true | true | :expected_allowed_paths
+ true | true | false | :expected_with_packages_path
+ end
+
+ with_them do
+ before do
+ stub_config(packages: {
+ enabled: package_features_enabled,
+ object_store: {
+ enabled: object_storage_enabled,
+ direct_upload: direct_upload_enabled
+ },
+ storage_path: '/any/dir'
+ })
+ end
+
+ it { is_expected.to eq(send(expected_paths)) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/middleware/multipart/handler_spec.rb b/spec/lib/gitlab/middleware/multipart/handler_spec.rb
new file mode 100644
index 00000000000..aac3f00defe
--- /dev/null
+++ b/spec/lib/gitlab/middleware/multipart/handler_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Middleware::Multipart::Handler do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:env) { Rack::MockRequest.env_for('/', method: 'post', params: {}) }
+ let_it_be(:message) { { 'rewritten_fields' => {} } }
+
+ describe '#allowed_paths' do
+ let_it_be(:expected_allowed_paths) do
+ [
+ Dir.tmpdir,
+ ::FileUploader.root,
+ ::Gitlab.config.uploads.storage_path,
+ ::JobArtifactUploader.workhorse_upload_path,
+ ::LfsObjectUploader.workhorse_upload_path,
+ File.join(Rails.root, 'public/uploads/tmp')
+ ]
+ end
+
+ let_it_be(:expected_with_packages_path) { expected_allowed_paths + [::Packages::PackageFileUploader.workhorse_upload_path] }
+
+ subject { described_class.new(env, message).send(:allowed_paths) }
+
+ where(:package_features_enabled, :object_storage_enabled, :direct_upload_enabled, :expected_paths) do
+ false | false | true | :expected_allowed_paths
+ false | false | false | :expected_allowed_paths
+ false | true | true | :expected_allowed_paths
+ false | true | false | :expected_allowed_paths
+ true | false | true | :expected_with_packages_path
+ true | false | false | :expected_with_packages_path
+ true | true | true | :expected_allowed_paths
+ true | true | false | :expected_with_packages_path
+ end
+
+ with_them do
+ before do
+ stub_config(packages: {
+ enabled: package_features_enabled,
+ object_store: {
+ enabled: object_storage_enabled,
+ direct_upload: direct_upload_enabled
+ },
+ storage_path: '/any/dir'
+ })
+ end
+
+ it { is_expected.to eq(send(expected_paths)) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/middleware/multipart_spec.rb b/spec/lib/gitlab/middleware/multipart_spec.rb
deleted file mode 100644
index 3b64fe335e8..00000000000
--- a/spec/lib/gitlab/middleware/multipart_spec.rb
+++ /dev/null
@@ -1,313 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-require 'tempfile'
-
-RSpec.describe Gitlab::Middleware::Multipart do
- include_context 'multipart middleware context'
-
- RSpec.shared_examples_for 'multipart upload files' do
- it 'opens top-level files' do
- Tempfile.open('top-level') do |tempfile|
- rewritten = { 'file' => tempfile.path }
- in_params = { 'file.name' => original_filename, 'file.path' => file_path, 'file.remote_id' => remote_id, 'file.size' => file_size }
- env = post_env(rewritten, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse')
-
- expect_uploaded_file(tempfile, %w(file))
-
- middleware.call(env)
- end
- end
-
- it 'opens files one level deep' do
- Tempfile.open('one-level') do |tempfile|
- rewritten = { 'user[avatar]' => tempfile.path }
- in_params = { 'user' => { 'avatar' => { '.name' => original_filename, '.path' => file_path, '.remote_id' => remote_id, '.size' => file_size } } }
- env = post_env(rewritten, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse')
-
- expect_uploaded_file(tempfile, %w(user avatar))
-
- middleware.call(env)
- end
- end
-
- it 'opens files two levels deep' do
- Tempfile.open('two-levels') do |tempfile|
- in_params = { 'project' => { 'milestone' => { 'themesong' => { '.name' => original_filename, '.path' => file_path, '.remote_id' => remote_id, '.size' => file_size } } } }
- rewritten = { 'project[milestone][themesong]' => tempfile.path }
- env = post_env(rewritten, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse')
-
- expect_uploaded_file(tempfile, %w(project milestone themesong))
-
- middleware.call(env)
- end
- end
-
- def expect_uploaded_file(tempfile, path)
- expect(app).to receive(:call) do |env|
- file = get_params(env).dig(*path)
- expect(file).to be_a(::UploadedFile)
- expect(file.original_filename).to eq(original_filename)
-
- if remote_id
- expect(file.remote_id).to eq(remote_id)
- expect(file.path).to be_nil
- else
- expect(file.path).to eq(File.realpath(tempfile.path))
- expect(file.remote_id).to be_nil
- end
- end
- end
- end
-
- RSpec.shared_examples_for 'handling CI artifact upload' do
- it 'uploads both file and metadata' do
- Tempfile.open('file') do |file|
- Tempfile.open('metadata') do |metadata|
- rewritten = { 'file' => file.path, 'metadata' => metadata.path }
- in_params = { 'file.name' => 'file.txt', 'file.path' => file_path, 'file.remote_id' => file_remote_id, 'file.size' => file_size, 'metadata.name' => 'metadata.gz' }
- env = post_env(rewritten, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse')
-
- with_expected_uploaded_artifact_files(file, metadata) do |uploaded_file, uploaded_metadata|
- expect(uploaded_file).to be_a(::UploadedFile)
- expect(uploaded_file.original_filename).to eq('file.txt')
-
- if file_remote_id
- expect(uploaded_file.remote_id).to eq(file_remote_id)
- expect(uploaded_file.size).to eq(file_size)
- expect(uploaded_file.path).to be_nil
- else
- expect(uploaded_file.path).to eq(File.realpath(file.path))
- expect(uploaded_file.remote_id).to be_nil
- end
-
- expect(uploaded_metadata).to be_a(::UploadedFile)
- expect(uploaded_metadata.original_filename).to eq('metadata.gz')
- expect(uploaded_metadata.path).to eq(File.realpath(metadata.path))
- expect(uploaded_metadata.remote_id).to be_nil
- end
-
- middleware.call(env)
- end
- end
- end
-
- def with_expected_uploaded_artifact_files(file, metadata)
- expect(app).to receive(:call) do |env|
- file = get_params(env).dig('file')
- metadata = get_params(env).dig('metadata')
-
- yield file, metadata
- end
- end
- end
-
- it 'rejects headers signed with the wrong secret' do
- env = post_env({ 'file' => '/var/empty/nonesuch' }, {}, 'x' * 32, 'gitlab-workhorse')
-
- expect { middleware.call(env) }.to raise_error(JWT::VerificationError)
- end
-
- it 'rejects headers signed with the wrong issuer' do
- env = post_env({ 'file' => '/var/empty/nonesuch' }, {}, Gitlab::Workhorse.secret, 'acme-inc')
-
- expect { middleware.call(env) }.to raise_error(JWT::InvalidIssuerError)
- end
-
- context 'with invalid rewritten field' do
- invalid_field_names = [
- '[file]',
- ';file',
- 'file]',
- ';file]',
- 'file]]',
- 'file;;'
- ]
-
- invalid_field_names.each do |invalid_field_name|
- it "rejects invalid rewritten field name #{invalid_field_name}" do
- env = post_env({ invalid_field_name => nil }, {}, Gitlab::Workhorse.secret, 'gitlab-workhorse')
-
- expect { middleware.call(env) }.to raise_error(RuntimeError, "invalid field: \"#{invalid_field_name}\"")
- end
- end
- end
-
- context 'with remote file' do
- let(:remote_id) { 'someid' }
- let(:file_size) { 300 }
- let(:file_path) { '' }
-
- it_behaves_like 'multipart upload files'
- end
-
- context 'with remote file and a file path set' do
- let(:remote_id) { 'someid' }
- let(:file_size) { 300 }
- let(:file_path) { 'not_a_valid_file_path' } # file path will come from the rewritten_fields
-
- it_behaves_like 'multipart upload files'
- end
-
- context 'with local file' do
- let(:remote_id) { nil }
- let(:file_size) { nil }
- let(:file_path) { 'not_a_valid_file_path' } # file path will come from the rewritten_fields
-
- it_behaves_like 'multipart upload files'
- end
-
- context 'with remote CI artifact upload' do
- let(:file_remote_id) { 'someid' }
- let(:file_size) { 300 }
- let(:file_path) { 'not_a_valid_file_path' } # file path will come from the rewritten_fields
-
- it_behaves_like 'handling CI artifact upload'
- end
-
- context 'with local CI artifact upload' do
- let(:file_remote_id) { nil }
- let(:file_size) { nil }
- let(:file_path) { 'not_a_valid_file_path' } # file path will come from the rewritten_fields
-
- it_behaves_like 'handling CI artifact upload'
- end
-
- it 'allows files in uploads/tmp directory' do
- with_tmp_dir('public/uploads/tmp') do |dir, env|
- expect(app).to receive(:call) do |env|
- expect(get_params(env)['file']).to be_a(::UploadedFile)
- end
-
- middleware.call(env)
- end
- end
-
- it 'allows files in the job artifact upload path' do
- with_tmp_dir('artifacts') do |dir, env|
- expect(JobArtifactUploader).to receive(:workhorse_upload_path).and_return(File.join(dir, 'artifacts'))
- expect(app).to receive(:call) do |env|
- expect(get_params(env)['file']).to be_a(::UploadedFile)
- end
-
- middleware.call(env)
- end
- end
-
- it 'allows files in the lfs upload path' do
- with_tmp_dir('lfs-objects') do |dir, env|
- expect(LfsObjectUploader).to receive(:workhorse_upload_path).and_return(File.join(dir, 'lfs-objects'))
- expect(app).to receive(:call) do |env|
- expect(get_params(env)['file']).to be_a(::UploadedFile)
- end
-
- middleware.call(env)
- end
- end
-
- it 'allows symlinks for uploads dir' do
- Tempfile.open('two-levels') do |tempfile|
- symlinked_dir = '/some/dir/uploads'
- symlinked_path = File.join(symlinked_dir, File.basename(tempfile.path))
- env = post_env({ 'file' => symlinked_path }, { 'file.name' => original_filename, 'file.path' => symlinked_path }, Gitlab::Workhorse.secret, 'gitlab-workhorse')
-
- allow(FileUploader).to receive(:root).and_return(symlinked_dir)
- allow(UploadedFile).to receive(:allowed_paths).and_return([symlinked_dir, Gitlab.config.uploads.storage_path])
- allow(File).to receive(:realpath).and_call_original
- allow(File).to receive(:realpath).with(symlinked_dir).and_return(Dir.tmpdir)
- allow(File).to receive(:realpath).with(symlinked_path).and_return(tempfile.path)
- allow(File).to receive(:exist?).and_call_original
- allow(File).to receive(:exist?).with(symlinked_dir).and_return(true)
-
- # override Dir.tmpdir because this dir is in the list of allowed paths
- # and it would match FileUploader.root path (which in this test is linked
- # to /tmp too)
- allow(Dir).to receive(:tmpdir).and_return(File.join(Dir.tmpdir, 'tmpsubdir'))
-
- expect(app).to receive(:call) do |env|
- expect(get_params(env)['file']).to be_a(::UploadedFile)
- end
-
- middleware.call(env)
- end
- end
-
- describe '#call' do
- context 'with packages storage' do
- using RSpec::Parameterized::TableSyntax
-
- let(:storage_path) { 'shared/packages' }
-
- RSpec.shared_examples 'allowing the multipart upload' do
- it 'allows files to be uploaded' do
- with_tmp_dir('tmp/uploads', storage_path) do |dir, env|
- allow(Packages::PackageFileUploader).to receive(:root).and_return(File.join(dir, storage_path))
-
- expect(app).to receive(:call) do |env|
- expect(get_params(env)['file']).to be_a(::UploadedFile)
- end
-
- middleware.call(env)
- end
- end
- end
-
- RSpec.shared_examples 'not allowing the multipart upload when package upload path is used' do
- it 'does not allow files to be uploaded' do
- with_tmp_dir('tmp/uploads', storage_path) do |dir, env|
- # with_tmp_dir sets the same workhorse_upload_path for all Uploaders,
- # so we have to prevent JobArtifactUploader and LfsObjectUploader to
- # allow the tested path
- allow(JobArtifactUploader).to receive(:workhorse_upload_path).and_return(Dir.tmpdir)
- allow(LfsObjectUploader).to receive(:workhorse_upload_path).and_return(Dir.tmpdir)
-
- status, headers, body = middleware.call(env)
-
- expect(status).to eq(400)
- expect(headers).to eq({ 'Content-Type' => 'text/plain' })
- expect(body).to start_with('insecure path used')
- end
- end
- end
-
- RSpec.shared_examples 'adding package storage to multipart allowed paths' do
- before do
- expect(::Packages::PackageFileUploader).to receive(:workhorse_upload_path).and_call_original
- end
-
- it_behaves_like 'allowing the multipart upload'
- end
-
- RSpec.shared_examples 'not adding package storage to multipart allowed paths' do
- before do
- expect(::Packages::PackageFileUploader).not_to receive(:workhorse_upload_path)
- end
-
- it_behaves_like 'not allowing the multipart upload when package upload path is used'
- end
-
- where(:object_storage_enabled, :direct_upload_enabled, :example_name) do
- false | true | 'adding package storage to multipart allowed paths'
- false | false | 'adding package storage to multipart allowed paths'
- true | true | 'not adding package storage to multipart allowed paths'
- true | false | 'adding package storage to multipart allowed paths'
- end
-
- with_them do
- before do
- stub_config(packages: {
- enabled: true,
- object_store: {
- enabled: object_storage_enabled,
- direct_upload: direct_upload_enabled
- },
- storage_path: storage_path
- })
- end
-
- it_behaves_like params[:example_name]
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/middleware/multipart_with_handler_for_jwt_params_spec.rb b/spec/lib/gitlab/middleware/multipart_with_handler_for_jwt_params_spec.rb
new file mode 100644
index 00000000000..875e3820011
--- /dev/null
+++ b/spec/lib/gitlab/middleware/multipart_with_handler_for_jwt_params_spec.rb
@@ -0,0 +1,171 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Middleware::Multipart do
+ include MultipartHelpers
+
+ describe '#call' do
+ let(:app) { double(:app) }
+ let(:middleware) { described_class.new(app) }
+ let(:secret) { Gitlab::Workhorse.secret }
+ let(:issuer) { 'gitlab-workhorse' }
+
+ subject do
+ env = post_env(
+ rewritten_fields: rewritten_fields,
+ params: params,
+ secret: secret,
+ issuer: issuer
+ )
+ middleware.call(env)
+ end
+
+ before do
+ stub_feature_flags(upload_middleware_jwt_params_handler: true)
+ end
+
+ context 'remote file mode' do
+ let(:mode) { :remote }
+
+ it_behaves_like 'handling all upload parameters conditions'
+
+ context 'and a path set' do
+ include_context 'with one temporary file for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) { upload_parameters_for(key: 'file', filename: filename, remote_id: remote_id).merge('file.path' => '/should/not/be/read') }
+
+ it 'builds an UploadedFile' do
+ expect_uploaded_files(original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file))
+
+ subject
+ end
+ end
+ end
+
+ context 'local file mode' do
+ let(:mode) { :local }
+
+ it_behaves_like 'handling all upload parameters conditions'
+
+ context 'when file is' do
+ include_context 'with one temporary file for multipart'
+
+ let(:allowed_paths) { [Dir.tmpdir] }
+
+ before do
+ expect_next_instance_of(::Gitlab::Middleware::Multipart::HandlerForJWTParams) do |handler|
+ expect(handler).to receive(:allowed_paths).and_return(allowed_paths)
+ end
+ end
+
+ context 'in allowed paths' do
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename) }
+
+ it 'builds an UploadedFile' do
+ expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, size: uploaded_file.size, params_path: %w(file))
+
+ subject
+ end
+ end
+
+ context 'not in allowed paths' do
+ let(:allowed_paths) { [] }
+
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file') }
+
+ it 'returns an error' do
+ result = subject
+
+ expect(result[0]).to eq(400)
+ expect(result[2]).to include('insecure path used')
+ end
+ end
+ end
+ end
+
+ context 'with dummy params in remote mode' do
+ let(:rewritten_fields) { { 'file' => 'should/not/be/read' } }
+ let(:params) { upload_parameters_for(key: 'file') }
+ let(:mode) { :remote }
+
+ context 'with an invalid secret' do
+ let(:secret) { 'INVALID_SECRET' }
+
+ it { expect { subject }.to raise_error(JWT::VerificationError) }
+ end
+
+ context 'with an invalid issuer' do
+ let(:issuer) { 'INVALID_ISSUER' }
+
+ it { expect { subject }.to raise_error(JWT::InvalidIssuerError) }
+ end
+
+ context 'with invalid rewritten field key' do
+ invalid_keys = [
+ '[file]',
+ ';file',
+ 'file]',
+ ';file]',
+ 'file]]',
+ 'file;;'
+ ]
+
+ invalid_keys.each do |invalid_key|
+ context invalid_key do
+ let(:rewritten_fields) { { invalid_key => 'should/not/be/read' } }
+
+ it { expect { subject }.to raise_error(RuntimeError, "invalid field: \"#{invalid_key}\"") }
+ end
+ end
+ end
+
+ context 'with invalid key in parameters' do
+ include_context 'with one temporary file for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'wrong_key', filename: filename, remote_id: remote_id) }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(RuntimeError, 'Empty JWT param: file.gitlab-workhorse-upload')
+ end
+ end
+
+ context 'with a modified JWT payload' do
+ include_context 'with one temporary file for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:crafted_payload) { Base64.urlsafe_encode64({ 'path' => 'test' }.to_json) }
+ let(:params) do
+ upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename, remote_id: remote_id).tap do |params|
+ header, _, sig = params['file.gitlab-workhorse-upload'].split('.')
+ params['file.gitlab-workhorse-upload'] = [header, crafted_payload, sig].join('.')
+ end
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(JWT::VerificationError, 'Signature verification raised')
+ end
+ end
+
+ context 'with a modified JWT sig' do
+ include_context 'with one temporary file for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) do
+ upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename, remote_id: remote_id).tap do |params|
+ header, payload, sig = params['file.gitlab-workhorse-upload'].split('.')
+ params['file.gitlab-workhorse-upload'] = [header, payload, "#{sig}modified"].join('.')
+ end
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(JWT::VerificationError, 'Signature verification raised')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/middleware/multipart_with_handler_spec.rb b/spec/lib/gitlab/middleware/multipart_with_handler_spec.rb
new file mode 100644
index 00000000000..742a5639ace
--- /dev/null
+++ b/spec/lib/gitlab/middleware/multipart_with_handler_spec.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Middleware::Multipart do
+ include MultipartHelpers
+
+ describe '#call' do
+ let(:app) { double(:app) }
+ let(:middleware) { described_class.new(app) }
+ let(:secret) { Gitlab::Workhorse.secret }
+ let(:issuer) { 'gitlab-workhorse' }
+
+ subject do
+ env = post_env(
+ rewritten_fields: rewritten_fields,
+ params: params,
+ secret: secret,
+ issuer: issuer
+ )
+ middleware.call(env)
+ end
+
+ before do
+ stub_feature_flags(upload_middleware_jwt_params_handler: false)
+ end
+
+ context 'remote file mode' do
+ let(:mode) { :remote }
+
+ it_behaves_like 'handling all upload parameters conditions'
+
+ context 'and a path set' do
+ include_context 'with one temporary file for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) { upload_parameters_for(key: 'file', filename: filename, remote_id: remote_id).merge('file.path' => '/should/not/be/read') }
+
+ it 'builds an UploadedFile' do
+ expect_uploaded_files(original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file))
+
+ subject
+ end
+ end
+ end
+
+ context 'local file mode' do
+ let(:mode) { :local }
+
+ it_behaves_like 'handling all upload parameters conditions'
+
+ context 'when file is' do
+ include_context 'with one temporary file for multipart'
+
+ let(:allowed_paths) { [Dir.tmpdir] }
+
+ before do
+ expect_next_instance_of(::Gitlab::Middleware::Multipart::Handler) do |handler|
+ expect(handler).to receive(:allowed_paths).and_return(allowed_paths)
+ end
+ end
+
+ context 'in allowed paths' do
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename) }
+
+ it 'builds an UploadedFile' do
+ expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, size: uploaded_file.size, params_path: %w(file))
+
+ subject
+ end
+ end
+
+ context 'not in allowed paths' do
+ let(:allowed_paths) { [] }
+
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file') }
+
+ it 'returns an error' do
+ result = subject
+
+ expect(result[0]).to eq(400)
+ expect(result[2]).to include('insecure path used')
+ end
+ end
+ end
+ end
+
+ context 'with dummy params in remote mode' do
+ let(:rewritten_fields) { { 'file' => 'should/not/be/read' } }
+ let(:params) { upload_parameters_for(key: 'file') }
+ let(:mode) { :remote }
+
+ context 'with an invalid secret' do
+ let(:secret) { 'INVALID_SECRET' }
+
+ it { expect { subject }.to raise_error(JWT::VerificationError) }
+ end
+
+ context 'with an invalid issuer' do
+ let(:issuer) { 'INVALID_ISSUER' }
+
+ it { expect { subject }.to raise_error(JWT::InvalidIssuerError) }
+ end
+
+ context 'with invalid rewritten field key' do
+ invalid_keys = [
+ '[file]',
+ ';file',
+ 'file]',
+ ';file]',
+ 'file]]',
+ 'file;;'
+ ]
+
+ invalid_keys.each do |invalid_key|
+ context invalid_key do
+ let(:rewritten_fields) { { invalid_key => 'should/not/be/read' } }
+
+ it { expect { subject }.to raise_error(RuntimeError, "invalid field: \"#{invalid_key}\"") }
+ end
+ end
+ end
+
+ context 'with invalid key in parameters' do
+ include_context 'with one temporary file for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'wrong_key', filename: filename, remote_id: remote_id) }
+
+ it 'builds no UploadedFile' do
+ expect(app).to receive(:call) do |env|
+ received_params = get_params(env)
+ expect(received_params['file']).to be_nil
+ expect(received_params['wrong_key']).to be_nil
+ end
+
+ subject
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/middleware/same_site_cookies_spec.rb b/spec/lib/gitlab/middleware/same_site_cookies_spec.rb
index 7c5262ca318..2d1a9b2eee2 100644
--- a/spec/lib/gitlab/middleware/same_site_cookies_spec.rb
+++ b/spec/lib/gitlab/middleware/same_site_cookies_spec.rb
@@ -3,18 +3,24 @@
require 'spec_helper'
RSpec.describe Gitlab::Middleware::SameSiteCookies do
+ using RSpec::Parameterized::TableSyntax
include Rack::Test::Methods
+ let(:user_agent) { nil }
let(:mock_app) do
Class.new do
- attr_reader :cookies
+ attr_reader :cookies, :user_agent
def initialize(cookies)
@cookies = cookies
end
def call(env)
- [200, { 'Set-Cookie' => cookies }, ['OK']]
+ [
+ 200,
+ { 'Set-Cookie' => cookies },
+ ['OK']
+ ]
end
end
end
@@ -29,7 +35,7 @@ RSpec.describe Gitlab::Middleware::SameSiteCookies do
let(:request) { Rack::MockRequest.new(subject) }
def do_request
- request.post('/some/path')
+ request.post('/some/path', { 'HTTP_USER_AGENT' => user_agent }.compact )
end
context 'without SSL enabled' do
@@ -63,6 +69,43 @@ RSpec.describe Gitlab::Middleware::SameSiteCookies do
end
end
+ context 'with different browsers' do
+ where(:description, :user_agent, :expected) do
+ "iOS 12" | "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1" | false
+ "macOS 10.14 + Safari" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Safari/605.1.15" | false
+ "macOS 10.14 + Opera" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.78 Safari/537.36 OPR/47.0.2631.55" | false
+ "macOS 10.14 + Chrome v80" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Safari/537.36" | true
+ "Chrome v41" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36" | true
+ "Chrome v50" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2348.1 Safari/537.36" | true
+ "Chrome v51" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2718.15 Safari/537.36" | false
+ "Chrome v62" | "Mozilla/5.0 (Macintosh; Intel NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.62 Safari/537.36" | false
+ "Chrome v66" | "Mozilla/5.0 (Linux; Android 4.4.2; Avvio_793 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.126 Mobile Safari/537.36" | false
+ "Chrome v67" | "Mozilla/5.0 (Linux; Android 7.1.1; SM-J510F Build/NMF26X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3371.0 Mobile Safari/537.36" | true
+ "Chrome v85" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36" | true
+ "Chromium v66" | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/66.0.3359.181 HeadlessChrome/66.0.3359.181 Safari/537.36" | false
+ "Chromium v85" | "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/85.0.4183.59 Chrome/85.0.4183.59 Safari/537.36" | true
+ "UC Browser 12.0.4" | "Mozilla/5.0 (Linux; U; Android 4.4.4; zh-CN; A31 Build/KTU84P) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.0.4.986 Mobile Safari/537.36" | false
+ "UC Browser 12.13.0" | "Mozilla/5.0 (Linux; U; Android 7.1.1; en-US; SM-C9000 Build/NMF26X) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.13.0.1207 Mobile Safari/537.36" | false
+ "UC Browser 12.13.2" | "Mozilla/5.0 (Linux; U; Android 9; en-US; Redmi Note 7 Build/PQ3B.190801.002) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.13.2.1208 Mobile Safari/537.36" | true
+ "UC Browser 12.13.5" | "Mozilla/5.0 (Linux; U; Android 5.1.1; en-US; PHICOMM C630 (CLUE L) Build/LMY47V) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.13.5.1209 Mobile Safari/537.36" | true
+ "Playstation" | "Mozilla/5.0 (PlayStation 4 2.51) AppleWebKit/537.73 (KHTML, like Gecko)" | true
+ end
+
+ with_them do
+ let(:cookies) { "thiscookie=12345" }
+
+ it 'returns expected SameSite status' do
+ response = do_request
+
+ if expected
+ expect(response['Set-Cookie']).to include('SameSite=None')
+ else
+ expect(response['Set-Cookie']).not_to include('SameSite=None')
+ end
+ end
+ end
+ end
+
context 'with single cookie' do
let(:cookies) { "thiscookie=12345" }
diff --git a/spec/lib/gitlab/pages/settings_spec.rb b/spec/lib/gitlab/pages/settings_spec.rb
index 7d4db073d73..f5424a98153 100644
--- a/spec/lib/gitlab/pages/settings_spec.rb
+++ b/spec/lib/gitlab/pages/settings_spec.rb
@@ -10,38 +10,14 @@ RSpec.describe Gitlab::Pages::Settings do
it { is_expected.to eq('the path') }
- it 'does not track calls' do
- expect(::Gitlab::ErrorTracking).not_to receive(:track_exception)
-
- subject
- end
-
- context 'when running under a web server' do
+ context 'when running under a web server outside of test mode' do
before do
+ allow(::Gitlab::Runtime).to receive(:test_suite?).and_return(false)
allow(::Gitlab::Runtime).to receive(:web_server?).and_return(true)
end
- it { is_expected.to eq('the path') }
-
- it 'does not track calls' do
- expect(::Gitlab::ErrorTracking).not_to receive(:track_exception)
-
- subject
- end
-
- context 'with the env var' do
- before do
- stub_env('GITLAB_PAGES_DENY_DISK_ACCESS', '1')
- end
-
- it { is_expected.to eq('the path') }
-
- it 'tracks a DiskAccessDenied exception' do
- expect(::Gitlab::ErrorTracking).to receive(:track_exception)
- .with(instance_of(described_class::DiskAccessDenied)).and_call_original
-
- subject
- end
+ it 'raises a DiskAccessDenied exception' do
+ expect { subject }.to raise_error(described_class::DiskAccessDenied)
end
end
end
diff --git a/spec/lib/gitlab/pages_transfer_spec.rb b/spec/lib/gitlab/pages_transfer_spec.rb
new file mode 100644
index 00000000000..4f0ee76b244
--- /dev/null
+++ b/spec/lib/gitlab/pages_transfer_spec.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::PagesTransfer do
+ describe '#async' do
+ let(:async) { subject.async }
+
+ context 'when receiving an allowed method' do
+ it 'schedules a PagesTransferWorker', :aggregate_failures do
+ described_class::Async::METHODS.each do |meth|
+ expect(PagesTransferWorker)
+ .to receive(:perform_async).with(meth, %w[foo bar])
+
+ async.public_send(meth, 'foo', 'bar')
+ end
+ end
+ end
+
+ context 'when receiving a private method' do
+ it 'raises NoMethodError' do
+ expect { async.move('foo', 'bar') }.to raise_error(NoMethodError)
+ end
+ end
+
+ context 'when receiving a non-existent method' do
+ it 'raises NoMethodError' do
+ expect { async.foo('bar') }.to raise_error(NoMethodError)
+ end
+ end
+ end
+
+ RSpec.shared_examples 'moving a pages directory' do |parameter|
+ let!(:pages_path_before) { project.pages_path }
+ let(:config_path_before) { File.join(pages_path_before, 'config.json') }
+ let(:pages_path_after) { project.reload.pages_path }
+ let(:config_path_after) { File.join(pages_path_after, 'config.json') }
+
+ before do
+ FileUtils.mkdir_p(pages_path_before)
+ FileUtils.touch(config_path_before)
+ end
+
+ after do
+ FileUtils.remove_entry(pages_path_before, true)
+ FileUtils.remove_entry(pages_path_after, true)
+ end
+
+ it 'moves the directory' do
+ subject.public_send(meth, *args)
+
+ expect(File.exist?(config_path_before)).to be(false)
+ expect(File.exist?(config_path_after)).to be(true)
+ end
+
+ it 'returns false if it fails to move the directory' do
+ # Move the directory once, so it can't be moved again
+ subject.public_send(meth, *args)
+
+ expect(subject.public_send(meth, *args)).to be(false)
+ end
+ end
+
+ describe '#move_namespace' do
+ # Can't use let_it_be because we change the path
+ let(:group_1) { create(:group) }
+ let(:group_2) { create(:group) }
+ let(:subgroup) { create(:group, parent: group_1) }
+ let(:project) { create(:project, group: subgroup) }
+ let(:new_path) { "#{group_2.path}/#{subgroup.path}" }
+ let(:meth) { 'move_namespace' }
+
+ # Store the path before we change it
+ let!(:args) { [project.path, subgroup.full_path, new_path] }
+
+ before do
+ # We need to skip hooks, otherwise the directory will be moved
+ # via an ActiveRecord callback
+ subgroup.update_columns(parent_id: group_2.id)
+ subgroup.route.update!(path: new_path)
+ end
+
+ include_examples 'moving a pages directory'
+ end
+
+ describe '#move_project' do
+ # Can't use let_it_be because we change the path
+ let(:group_1) { create(:group) }
+ let(:group_2) { create(:group) }
+ let(:project) { create(:project, group: group_1) }
+ let(:new_path) { group_2.path }
+ let(:meth) { 'move_project' }
+ let(:args) { [project.path, group_1.full_path, group_2.full_path] }
+
+ include_examples 'moving a pages directory' do
+ before do
+ project.update!(group: group_2)
+ end
+ end
+ end
+
+ describe '#rename_project' do
+ # Can't use let_it_be because we change the path
+ let(:project) { create(:project) }
+ let(:new_path) { project.path.succ }
+ let(:meth) { 'rename_project' }
+
+ # Store the path before we change it
+ let!(:args) { [project.path, new_path, project.namespace.full_path] }
+
+ include_examples 'moving a pages directory' do
+ before do
+ project.update!(path: new_path)
+ end
+ end
+ end
+
+ describe '#rename_namespace' do
+ # Can't use let_it_be because we change the path
+ let(:group) { create(:group) }
+ let(:project) { create(:project, group: group) }
+ let(:new_path) { project.namespace.full_path.succ }
+ let(:meth) { 'rename_namespace' }
+
+ # Store the path before we change it
+ let!(:args) { [project.namespace.full_path, new_path] }
+
+ before do
+ # We need to skip hooks, otherwise the directory will be moved
+ # via an ActiveRecord callback
+ group.update_columns(path: new_path)
+ group.route.update!(path: new_path)
+ end
+
+ include_examples 'moving a pages directory'
+ end
+end
diff --git a/spec/lib/gitlab/phabricator_import/cache/map_spec.rb b/spec/lib/gitlab/phabricator_import/cache/map_spec.rb
index 0f760852a68..08ac85c2625 100644
--- a/spec/lib/gitlab/phabricator_import/cache/map_spec.rb
+++ b/spec/lib/gitlab/phabricator_import/cache/map_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe Gitlab::PhabricatorImport::Cache::Map, :clean_gitlab_redis_cache
describe '#set_gitlab_model' do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'sets the class and id in redis with a ttl' do
diff --git a/spec/lib/gitlab/project_authorizations_spec.rb b/spec/lib/gitlab/project_authorizations_spec.rb
index 5ff07dcec4f..d2b41ee31d9 100644
--- a/spec/lib/gitlab/project_authorizations_spec.rb
+++ b/spec/lib/gitlab/project_authorizations_spec.rb
@@ -115,6 +115,66 @@ RSpec.describe Gitlab::ProjectAuthorizations do
end
end
+ 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) }
+
+ context 'group membership' do
+ let!(:group_project) { create(:project, namespace: group) }
+
+ 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
+ end
+ end
+
+ 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
+
+ it 'does not create authorization' do
+ expect(mapping[sub_group_project.id]).to be_nil
+ end
+ end
+
+ 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
+
+ it 'does not create authorization' do
+ expect(mapping[shared_group_project.id]).to be_nil
+ end
+ end
+
+ 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
+
+ it 'does not create authorization' do
+ expect(mapping[shared_project.id]).to be_nil
+ end
+ end
+ end
+
context 'with nested groups' do
let(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb
index 6e3c60b58dc..fe0735b8043 100644
--- a/spec/lib/gitlab/project_search_results_spec.rb
+++ b/spec/lib/gitlab/project_search_results_spec.rb
@@ -5,31 +5,34 @@ require 'spec_helper'
RSpec.describe Gitlab::ProjectSearchResults do
include SearchHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
let(:query) { 'hello world' }
+ let(:repository_ref) { nil }
+ let(:filters) { {} }
- describe 'initialize with empty ref' do
- let(:results) { described_class.new(user, project, query, '') }
+ subject(:results) { described_class.new(user, query, project: project, repository_ref: repository_ref, filters: filters) }
- it { expect(results.project).to eq(project) }
- it { expect(results.query).to eq('hello world') }
- end
+ context 'with a repository_ref' do
+ context 'when empty' do
+ let(:repository_ref) { '' }
+
+ it { expect(results.project).to eq(project) }
+ it { expect(results.query).to eq('hello world') }
+ end
- describe 'initialize with ref' do
- let(:ref) { 'refs/heads/test' }
- let(:results) { described_class.new(user, project, query, ref) }
+ context 'when set' do
+ let(:repository_ref) { 'refs/heads/test' }
- it { expect(results.project).to eq(project) }
- it { expect(results.repository_ref).to eq(ref) }
- it { expect(results.query).to eq('hello world') }
+ it { expect(results.project).to eq(project) }
+ it { expect(results.repository_ref).to eq(repository_ref) }
+ it { expect(results.query).to eq('hello world') }
+ end
end
describe '#formatted_count' do
using RSpec::Parameterized::TableSyntax
- let(:results) { described_class.new(user, project, query) }
-
where(:scope, :count_method, :expected) do
'blobs' | :limited_blobs_count | max_limited_count
'notes' | :limited_notes_count | max_limited_count
@@ -63,7 +66,8 @@ RSpec.describe Gitlab::ProjectSearchResults do
shared_examples 'general blob search' do |entity_type, blob_type|
let(:query) { 'files' }
- subject(:results) { described_class.new(user, project, query).objects(blob_type) }
+
+ subject(:objects) { results.objects(blob_type) }
context "when #{entity_type} is disabled" do
let(:project) { disabled_project }
@@ -94,17 +98,17 @@ RSpec.describe Gitlab::ProjectSearchResults do
end
it 'finds by name' do
- expect(results.map(&:path)).to include(expected_file_by_path)
+ expect(objects.map(&:path)).to include(expected_file_by_path)
end
it "loads all blobs for path matches in single batch" do
expect(Gitlab::Git::Blob).to receive(:batch).once.and_call_original
- results.map(&:data)
+ expect { objects.map(&:data) }.not_to raise_error
end
it 'finds by content' do
- blob = results.select { |result| result.path == expected_file_by_content }.flatten.last
+ blob = objects.select { |result| result.path == expected_file_by_content }.flatten.last
expect(blob.path).to eq(expected_file_by_content)
end
@@ -115,7 +119,7 @@ RSpec.describe Gitlab::ProjectSearchResults do
let(:file_finder) { double }
let(:project_branch) { 'project_branch' }
- subject(:results) { described_class.new(user, project, query, repository_ref).objects(blob_type) }
+ subject(:objects) { results.objects(blob_type) }
before do
allow(entity).to receive(:default_branch).and_return(project_branch)
@@ -128,7 +132,7 @@ RSpec.describe Gitlab::ProjectSearchResults do
it 'uses it' do
expect(Gitlab::FileFinder).to receive(:new).with(project, repository_ref).and_return(file_finder)
- results
+ expect { objects }.not_to raise_error
end
end
@@ -138,7 +142,7 @@ RSpec.describe Gitlab::ProjectSearchResults do
it "uses #{entity_type} repository default reference" do
expect(Gitlab::FileFinder).to receive(:new).with(project, project_branch).and_return(file_finder)
- results
+ expect { objects }.not_to raise_error
end
end
@@ -148,7 +152,7 @@ RSpec.describe Gitlab::ProjectSearchResults do
it "uses #{entity_type} repository default reference" do
expect(Gitlab::FileFinder).to receive(:new).with(project, project_branch).and_return(file_finder)
- results
+ expect { objects }.not_to raise_error
end
end
end
@@ -157,7 +161,6 @@ RSpec.describe Gitlab::ProjectSearchResults do
let(:per_page) { 20 }
let(:count_limit) { described_class::COUNT_LIMIT }
let(:file_finder) { instance_double('Gitlab::FileFinder') }
- let(:results) { described_class.new(user, project, query) }
let(:repository_ref) { 'master' }
before do
@@ -228,139 +231,97 @@ RSpec.describe Gitlab::ProjectSearchResults do
context 'return type' do
let(:blobs) { [Gitlab::Search::FoundBlob.new(project: project)] }
- let(:results) { described_class.new(user, project, "Files", per_page: 20) }
+ let(:query) { "Files" }
+
+ subject(:objects) { results.objects('wiki_blobs', per_page: 20) }
before do
allow(results).to receive(:wiki_blobs).and_return(blobs)
end
it 'returns list of FoundWikiPage type object' do
- objects = results.objects('wiki_blobs')
-
expect(objects).to be_present
expect(objects).to all(be_a(Gitlab::Search::FoundWikiPage))
end
end
end
- it 'does not list issues on private projects' do
- issue = create(:issue, project: project)
-
- results = described_class.new(user, project, issue.title)
-
- expect(results.objects('issues')).not_to include issue
- end
-
- describe 'confidential issues' do
- let(:query) { 'issue' }
- let(:author) { create(:user) }
- let(:assignee) { create(:user) }
- let(:non_member) { create(:user) }
- let(:member) { create(:user) }
- let(:admin) { create(:admin) }
- let(:project) { create(:project, :internal) }
- let!(:issue) { create(:issue, project: project, title: 'Issue 1') }
- let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) }
- let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) }
-
- it 'does not list project confidential issues for non project members' do
- results = described_class.new(non_member, project, query)
- issues = results.objects('issues')
-
- expect(issues).to include issue
- expect(issues).not_to include security_issue_1
- expect(issues).not_to include security_issue_2
- expect(results.limited_issues_count).to eq 1
- end
-
- it 'does not list project confidential issues for project members with guest role' do
- project.add_guest(member)
+ describe 'issues search' do
+ let(:issue) { create(:issue, project: project) }
+ let(:query) { issue.title }
+ let(:scope) { 'issues' }
- results = described_class.new(member, project, query)
- issues = results.objects('issues')
+ subject(:objects) { results.objects(scope) }
- expect(issues).to include issue
- expect(issues).not_to include security_issue_1
- expect(issues).not_to include security_issue_2
- expect(results.limited_issues_count).to eq 1
+ it 'does not list issues on private projects' do
+ expect(objects).not_to include issue
end
- it 'lists project confidential issues for author' do
- results = described_class.new(author, project, query)
- issues = results.objects('issues')
-
- expect(issues).to include issue
- expect(issues).to include security_issue_1
- expect(issues).not_to include security_issue_2
- expect(results.limited_issues_count).to eq 2
+ describe "confidential issues" do
+ include_examples "access restricted confidential issues"
end
- it 'lists project confidential issues for assignee' do
- results = described_class.new(assignee, project, query)
- issues = results.objects('issues')
+ context 'filtering' do
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') }
+ let_it_be(:opened_result) { create(:issue, :opened, project: project, title: 'foo opened') }
+ let(:query) { 'foo' }
- expect(issues).to include issue
- expect(issues).not_to include security_issue_1
- expect(issues).to include security_issue_2
- expect(results.limited_issues_count).to eq 2
+ include_examples 'search results filtered by state'
end
+ end
- it 'lists project confidential issues for project members' do
- project.add_developer(member)
-
- results = described_class.new(member, project, query)
- issues = results.objects('issues')
-
- expect(issues).to include issue
- expect(issues).to include security_issue_1
- expect(issues).to include security_issue_2
- expect(results.limited_issues_count).to eq 3
- end
+ describe 'merge requests search' do
+ let(:scope) { 'merge_requests' }
+ let(:project) { create(:project, :public) }
- it 'lists all project issues for admin' do
- results = described_class.new(admin, project, query)
- issues = results.objects('issues')
+ context 'filtering' do
+ let!(:project) { create(:project, :public) }
+ let!(:opened_result) { create(:merge_request, :opened, source_project: project, title: 'foo opened') }
+ let!(:closed_result) { create(:merge_request, :closed, source_project: project, title: 'foo closed') }
+ let(:query) { 'foo' }
- expect(issues).to include issue
- expect(issues).to include security_issue_1
- expect(issues).to include security_issue_2
- expect(results.limited_issues_count).to eq 3
+ include_examples 'search results filtered by state'
end
end
describe 'notes search' do
- it 'lists notes' do
- project = create(:project, :public)
- note = create(:note, project: project)
+ let(:query) { note.note }
- results = described_class.new(user, project, note.note)
+ subject(:notes) { results.objects('notes') }
- expect(results.objects('notes')).to include note
- end
+ context 'with a public project' do
+ let(:project) { create(:project, :public) }
+ let(:note) { create(:note, project: project) }
- it "doesn't list issue notes when access is restricted" do
- project = create(:project, :public, :issues_private)
- note = create(:note_on_issue, project: project)
+ it 'lists notes' do
+ expect(notes).to include note
+ end
+ end
- results = described_class.new(user, project, note.note)
+ context 'with private issues' do
+ let(:project) { create(:project, :public, :issues_private) }
+ let(:note) { create(:note_on_issue, project: project) }
- expect(results.objects('notes')).not_to include note
+ it "doesn't list issue notes when access is restricted" do
+ expect(notes).not_to include note
+ end
end
- it "doesn't list merge_request notes when access is restricted" do
- project = create(:project, :public, :merge_requests_private)
- note = create(:note_on_merge_request, project: project)
+ context 'with private merge requests' do
+ let(:project) { create(:project, :public, :merge_requests_private) }
+ let(:note) { create(:note_on_merge_request, project: project) }
- results = described_class.new(user, project, note.note)
-
- expect(results.objects('notes')).not_to include note
+ it "doesn't list merge_request notes when access is restricted" do
+ expect(notes).not_to include note
+ end
end
end
describe '#limited_notes_count' do
let(:project) { create(:project, :public) }
let(:note) { create(:note_on_issue, project: project) }
- let(:results) { described_class.new(user, project, note.note) }
+ let(:query) { note.note }
context 'when count_limit is lower than total amount' do
before do
@@ -375,11 +336,6 @@ RSpec.describe Gitlab::ProjectSearchResults do
context 'when count_limit is higher than total amount' do
it 'calls note finder multiple times to get the limited amount of notes' do
- project = create(:project, :public)
- note = create(:note_on_issue, project: project)
-
- results = described_class.new(user, project, note.note)
-
expect(results).to receive(:notes_finder).exactly(4).times.and_call_original
expect(results.limited_notes_count).to eq(1)
end
@@ -395,7 +351,7 @@ RSpec.describe Gitlab::ProjectSearchResults do
.with(anything, anything, anything, described_class::COUNT_LIMIT)
.and_call_original
- described_class.new(user, project, '.').commits_count
+ results.commits_count
end
end
@@ -406,19 +362,23 @@ RSpec.describe Gitlab::ProjectSearchResults do
# * commit
#
shared_examples 'access restricted commits' do
+ let(:query) { search_phrase }
+
context 'when project is internal' do
let(:project) { create(:project, :internal, :repository) }
- it 'does not search if user is not authenticated' do
- commits = described_class.new(nil, project, search_phrase).objects('commits')
+ subject(:commits) { results.objects('commits') }
- expect(commits).to be_empty
+ it 'searches if user is authenticated' do
+ expect(commits).to contain_exactly commit
end
- it 'searches if user is authenticated' do
- commits = described_class.new(user, project, search_phrase).objects('commits')
+ context 'when the user is not authenticated' do
+ let(:user) { nil }
- expect(commits).to contain_exactly commit
+ it 'does not search' do
+ expect(commits).to be_empty
+ end
end
end
@@ -437,29 +397,35 @@ RSpec.describe Gitlab::ProjectSearchResults do
user
end
- it 'does not show commit to stranger' do
- commits = described_class.new(nil, private_project, search_phrase).objects('commits')
+ let(:project) { private_project }
- expect(commits).to be_empty
+ subject(:commits) { results.objects('commits') }
+
+ context 'when the user is not authenticated' do
+ let(:user) { nil }
+
+ it 'does not show commit to stranger' do
+ expect(commits).to be_empty
+ end
end
context 'team access' do
- it 'shows commit to creator' do
- commits = described_class.new(creator, private_project, search_phrase).objects('commits')
+ context 'when the user is the creator' do
+ let(:user) { creator }
- expect(commits).to contain_exactly commit
+ it { expect(commits).to contain_exactly commit }
end
- it 'shows commit to master' do
- commits = described_class.new(team_master, private_project, search_phrase).objects('commits')
+ context 'when the user is a master' do
+ let(:user) { team_master }
- expect(commits).to contain_exactly commit
+ it { expect(commits).to contain_exactly commit }
end
- it 'shows commit to reporter' do
- commits = described_class.new(team_reporter, private_project, search_phrase).objects('commits')
+ context 'when the user is a reporter' do
+ let(:user) { team_reporter }
- expect(commits).to contain_exactly commit
+ it { expect(commits).to contain_exactly commit }
end
end
end
@@ -471,9 +437,7 @@ RSpec.describe Gitlab::ProjectSearchResults do
it 'returns the correct results for each page' do
expect(results_page(1)).to contain_exactly(commit('b83d6e391c22777fca1ed3012fce84f633d7fed0'))
-
expect(results_page(2)).to contain_exactly(commit('498214de67004b1da3d820901307bed2a68a8ef6'))
-
expect(results_page(3)).to contain_exactly(commit('1b12f15a11fc6e62177bef08f47bc7b5ce50b141'))
end
@@ -506,7 +470,7 @@ RSpec.describe Gitlab::ProjectSearchResults do
end
def results_page(page)
- described_class.new(user, project, '.').objects('commits', per_page: 1, page: page)
+ described_class.new(user, '.', project: project).objects('commits', per_page: 1, page: page)
end
def commit(hash)
@@ -518,26 +482,27 @@ RSpec.describe Gitlab::ProjectSearchResults do
let(:project) { create(:project, :public, :repository) }
let(:commit) { project.repository.commit('59e29889be61e6e0e5e223bfa9ac2721d31605b8') }
let(:message) { 'Sorry, I did a mistake' }
+ let(:query) { message }
- it 'finds commit by message' do
- commits = described_class.new(user, project, message).objects('commits')
+ subject(:commits) { results.objects('commits') }
+ it 'finds commit by message' do
expect(commits).to contain_exactly commit
end
- it 'handles when no commit match' do
- commits = described_class.new(user, project, 'not really an existing description').objects('commits')
+ context 'when there are not hits' do
+ let(:query) { 'not really an existing description' }
- expect(commits).to be_empty
+ it 'handles when no commit match' do
+ expect(commits).to be_empty
+ end
end
context 'when repository_ref is provided' do
- let(:message) { 'Feature added' }
+ let(:query) { 'Feature added' }
let(:repository_ref) { 'feature' }
it 'searches in the specified ref' do
- commits = described_class.new(user, project, message, repository_ref).objects('commits')
-
# This commit is unique to the feature branch
expect(commits).to contain_exactly(project.repository.commit('0b4bc9a49b562e85de7cc9e834518ea6828729b9'))
end
@@ -557,14 +522,14 @@ RSpec.describe Gitlab::ProjectSearchResults do
commit_hashes.each do |type, commit_hash|
it "shows commit by #{type} hash id" do
- commits = described_class.new(user, project, commit_hash).objects('commits')
+ commits = described_class.new(user, commit_hash, project: project).objects('commits')
expect(commits).to contain_exactly commit
end
end
it 'handles not existing commit hash correctly' do
- commits = described_class.new(user, project, 'deadbeef').objects('commits')
+ commits = described_class.new(user, 'deadbeef', project: project).objects('commits')
expect(commits).to be_empty
end
@@ -577,9 +542,13 @@ RSpec.describe Gitlab::ProjectSearchResults do
end
describe 'user search' do
- it 'returns the user belonging to the project matching the search query' do
- project = create(:project)
+ let(:query) { 'gob' }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, namespace: group) }
+ subject(:objects) { results.objects('users') }
+
+ it 'returns the user belonging to the project matching the search query' do
user1 = create(:user, username: 'gob_bluth')
create(:project_member, :developer, user: user1, project: project)
@@ -588,23 +557,16 @@ RSpec.describe Gitlab::ProjectSearchResults do
create(:user, username: 'gob_2018')
- result = described_class.new(user, project, 'gob').objects('users')
-
- expect(result).to eq [user1]
+ expect(objects).to contain_exactly(user1)
end
it 'returns the user belonging to the group matching the search query' do
- group = create(:group)
- project = create(:project, namespace: group)
-
user1 = create(:user, username: 'gob_bluth')
create(:group_member, :developer, user: user1, group: group)
create(:user, username: 'gob_2018')
- result = described_class.new(user, project, 'gob').objects('users')
-
- expect(result).to eq [user1]
+ expect(objects).to contain_exactly(user1)
end
end
end
diff --git a/spec/lib/gitlab/prometheus/internal_spec.rb b/spec/lib/gitlab/prometheus/internal_spec.rb
index 1254610fe32..7771d85222a 100644
--- a/spec/lib/gitlab/prometheus/internal_spec.rb
+++ b/spec/lib/gitlab/prometheus/internal_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe Gitlab::Prometheus::Internal do
let(:listen_address) { nil }
it 'does not fail' do
- expect(described_class.uri).to eq(nil)
+ expect(described_class.uri).to be_nil
end
end
@@ -56,12 +56,32 @@ RSpec.describe Gitlab::Prometheus::Internal do
let(:listen_address) { '' }
it 'does not configure prometheus' do
- expect(described_class.uri).to eq(nil)
+ expect(described_class.uri).to be_nil
end
end
end
- describe 'prometheus_enabled?' do
+ describe '.server_address' do
+ context 'self.uri returns valid uri' do
+ ['http://localhost:9090', 'https://localhost:9090 '].each do |valid_uri|
+ it 'returns correct server address' do
+ expect(described_class).to receive(:uri).and_return(valid_uri)
+
+ expect(described_class.server_address).to eq('localhost:9090')
+ end
+ end
+ end
+
+ context 'self.uri returns nil' do
+ it 'returns nil' do
+ expect(described_class).to receive(:uri).and_return(nil)
+
+ expect(described_class.server_address).to be_nil
+ end
+ end
+ end
+
+ describe '.prometheus_enabled?' do
it 'returns correct value' do
expect(described_class.prometheus_enabled?).to eq(true)
end
@@ -101,7 +121,7 @@ RSpec.describe Gitlab::Prometheus::Internal do
end
it 'does not fail' do
- expect(described_class.listen_address).to eq(nil)
+ expect(described_class.listen_address).to be_nil
end
end
end
diff --git a/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb
index f5911963108..d0dee2ad366 100644
--- a/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb
+++ b/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Prometheus::Queries::AdditionalMetricsEnvironmentQuery do
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
include_examples 'additional metrics query' do
diff --git a/spec/lib/gitlab/prometheus/queries/validate_query_spec.rb b/spec/lib/gitlab/prometheus/queries/validate_query_spec.rb
index 045c063ab34..e3706a4b106 100644
--- a/spec/lib/gitlab/prometheus/queries/validate_query_spec.rb
+++ b/spec/lib/gitlab/prometheus/queries/validate_query_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Gitlab::Prometheus::Queries::ValidateQuery do
let(:error_message) { "invalid parameter 'query': 1:9: parse error: unexpected identifier \"query\"" }
it 'returns invalid' do
- Timecop.freeze do
+ freeze_time do
stub_prometheus_query_error(
prometheus_query_with_time_url(query, Time.now),
error_message
@@ -53,7 +53,7 @@ RSpec.describe Gitlab::Prometheus::Queries::ValidateQuery do
end
it 'catches exception and returns invalid' do
- Timecop.freeze do
+ freeze_time do
expect(subject.query(query)).to eq(valid: false, error: message)
end
end
diff --git a/spec/lib/gitlab/prometheus_client_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb
index 0774c2f3144..82ef4675553 100644
--- a/spec/lib/gitlab/prometheus_client_spec.rb
+++ b/spec/lib/gitlab/prometheus_client_spec.rb
@@ -36,6 +36,28 @@ RSpec.describe Gitlab::PrometheusClient do
end
end
+ describe '#ready?' do
+ it 'returns true when status code is 200' do
+ stub_request(:get, subject.ready_url).to_return(status: 200, body: 'Prometheus is Ready.\n')
+
+ expect(subject.ready?).to eq(true)
+ end
+
+ it 'returns false when status code is not 200' do
+ [503, 500].each do |code|
+ stub_request(:get, subject.ready_url).to_return(status: code, body: 'Service Unavailable')
+
+ expect(subject.ready?).to eq(false)
+ end
+ end
+
+ it 'raises error when ready api throws exception' do
+ stub_request(:get, subject.ready_url).to_raise(Net::OpenTimeout)
+
+ expect { subject.ready? }.to raise_error(Gitlab::PrometheusClient::UnexpectedResponseError)
+ end
+ end
+
# This shared examples expect:
# - query_url: A query URL
# - execute_query: A query call
@@ -136,7 +158,7 @@ RSpec.describe Gitlab::PrometheusClient do
let(:query_url) { prometheus_query_with_time_url(prometheus_query, Time.now.utc) }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'when request returns vector results' do
@@ -195,7 +217,7 @@ RSpec.describe Gitlab::PrometheusClient do
let(:query_url) { prometheus_query_with_time_url(query, Time.now.utc) }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'when request returns vector results' do
@@ -228,7 +250,7 @@ RSpec.describe Gitlab::PrometheusClient do
let(:query_url) { prometheus_series_url('series_name', 'other_service') }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'calls endpoint and returns list of series' do
@@ -259,7 +281,7 @@ RSpec.describe Gitlab::PrometheusClient do
let(:query_url) { prometheus_query_range_url(prometheus_query) }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'when non utc time is passed' do
@@ -358,7 +380,7 @@ RSpec.describe Gitlab::PrometheusClient do
let(:query_url) { prometheus_query_url(prometheus_query) }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'when response status code is 200' do
diff --git a/spec/lib/gitlab/quick_actions/substitution_definition_spec.rb b/spec/lib/gitlab/quick_actions/substitution_definition_spec.rb
index b28ac49b4ea..8a4e9ab8bb7 100644
--- a/spec/lib/gitlab/quick_actions/substitution_definition_spec.rb
+++ b/spec/lib/gitlab/quick_actions/substitution_definition_spec.rb
@@ -46,24 +46,4 @@ EOF
end
end
end
-
- describe '#match' do
- it 'checks the content for the command' do
- expect(subject.match(content)).to be_truthy
- end
-
- it 'returns the match data' do
- data = subject.match(content)
- expect(data).to be_a(MatchData)
- expect(data[1]).to eq('I like this stuff')
- end
-
- it 'is nil if content does not have the command' do
- expect(subject.match('blah')).to be_falsey
- end
-
- it 'is nil if content contains the command as prefix' do
- expect(subject.match('/sub_namex')).to be_falsey
- end
- end
end
diff --git a/spec/lib/gitlab/reference_counter_spec.rb b/spec/lib/gitlab/reference_counter_spec.rb
index 0d0ac75ee22..83e4006c69b 100644
--- a/spec/lib/gitlab/reference_counter_spec.rb
+++ b/spec/lib/gitlab/reference_counter_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe Gitlab::ReferenceCounter, :clean_gitlab_redis_shared_state do
end
it 'warns if attempting to decrease a counter with a value of zero or less, and resets the counter' do
- expect(Rails.logger).to receive(:warn).with("Reference counter for project-1" \
+ expect(Gitlab::AppLogger).to receive(:warn).with("Reference counter for project-1" \
" decreased when its value was less than 1. Resetting the counter.")
expect { reference_counter.decrease }.not_to change { reference_counter.value }
end
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index afa930b795a..88c3315150b 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -3,14 +3,19 @@
require 'fast_spec_helper'
RSpec.describe Gitlab::Regex do
- shared_examples_for 'project/group name regex' do
+ shared_examples_for 'project/group name chars regex' do
it { is_expected.to match('gitlab-ce') }
it { is_expected.to match('GitLab CE') }
it { is_expected.to match('100 lines') }
it { is_expected.to match('gitlab.git') }
it { is_expected.to match('Český název') }
it { is_expected.to match('Dash – is this') }
+ end
+
+ shared_examples_for 'project/group name regex' do
+ it_behaves_like 'project/group name chars regex'
it { is_expected.not_to match('?gitlab') }
+ it { is_expected.not_to match("Users's something") }
end
describe '.project_name_regex' do
@@ -33,6 +38,16 @@ RSpec.describe Gitlab::Regex do
end
end
+ describe '.group_name_regex_chars' do
+ subject { described_class.group_name_regex_chars }
+
+ it_behaves_like 'project/group name chars regex'
+
+ it 'allows partial matches' do
+ is_expected.to match(',Valid name wrapped in ivalid chars&')
+ end
+ end
+
describe '.project_name_regex_message' do
subject { described_class.project_name_regex_message }
@@ -302,6 +317,73 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('%2e%2e%2f1.2.3') }
end
+ describe '.pypi_version_regex' do
+ subject { described_class.pypi_version_regex }
+
+ it { is_expected.to match('0.1') }
+ it { is_expected.to match('2.0') }
+ it { is_expected.to match('1.2.0')}
+ it { is_expected.to match('0100!0.0') }
+ it { is_expected.to match('00!1.2') }
+ it { is_expected.to match('1.0a') }
+ it { is_expected.to match('1.0-a') }
+ it { is_expected.to match('1.0.a1') }
+ it { is_expected.to match('1.0a1') }
+ it { is_expected.to match('1.0-a1') }
+ it { is_expected.to match('1.0alpha1') }
+ it { is_expected.to match('1.0b1') }
+ it { is_expected.to match('1.0beta1') }
+ it { is_expected.to match('1.0rc1') }
+ it { is_expected.to match('1.0pre1') }
+ it { is_expected.to match('1.0preview1') }
+ it { is_expected.to match('1.0.dev1') }
+ it { is_expected.to match('1.0.DEV1') }
+ it { is_expected.to match('1.0.post1') }
+ it { is_expected.to match('1.0.rev1') }
+ it { is_expected.to match('1.0.r1') }
+ it { is_expected.to match('1.0c2') }
+ it { is_expected.to match('2012.15') }
+ it { is_expected.to match('1.0+5') }
+ it { is_expected.to match('1.0+abc.5') }
+ it { is_expected.to match('1!1.1') }
+ it { is_expected.to match('1.0c3') }
+ it { is_expected.to match('1.0rc2') }
+ it { is_expected.to match('1.0c1') }
+ it { is_expected.to match('1.0b2-346') }
+ it { is_expected.to match('1.0b2.post345') }
+ it { is_expected.to match('1.0b2.post345.dev456') }
+ it { is_expected.to match('1.2.rev33+123456') }
+ it { is_expected.to match('1.1.dev1') }
+ it { is_expected.to match('1.0b1.dev456') }
+ it { is_expected.to match('1.0a12.dev456') }
+ it { is_expected.to match('1.0b2') }
+ it { is_expected.to match('1.0.dev456') }
+ it { is_expected.to match('1.0c1.dev456') }
+ it { is_expected.to match('1.0.post456') }
+ it { is_expected.to match('1.0.post456.dev34') }
+ it { is_expected.to match('1.2+123abc') }
+ it { is_expected.to match('1.2+abc') }
+ it { is_expected.to match('1.2+abc123') }
+ it { is_expected.to match('1.2+abc123def') }
+ it { is_expected.to match('1.2+1234.abc') }
+ it { is_expected.to match('1.2+123456') }
+ it { is_expected.to match('1.2.r32+123456') }
+ it { is_expected.to match('1!1.2.rev33+123456') }
+ it { is_expected.to match('1.0a12') }
+ it { is_expected.to match('1.2.3-45+abcdefgh') }
+ it { is_expected.to match('v1.2.3') }
+ it { is_expected.not_to match('1.2.3-45-abcdefgh') }
+ it { is_expected.not_to match('..1.2.3') }
+ it { is_expected.not_to match(' 1.2.3') }
+ it { is_expected.not_to match("1.2.3 \r\t") }
+ it { is_expected.not_to match("\r\t 1.2.3") }
+ it { is_expected.not_to match('1./2.3') }
+ it { is_expected.not_to match('1.2.3-4/../../') }
+ it { is_expected.not_to match('1.2.3-4%2e%2e%') }
+ it { is_expected.not_to match('../../../../../1.2.3') }
+ it { is_expected.not_to match('%2e%2e%2f1.2.3') }
+ end
+
describe '.semver_regex' do
subject { described_class.semver_regex }
@@ -335,4 +417,21 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('1.2') }
it { is_expected.not_to match('1./2.3') }
end
+
+ describe '.generic_package_version_regex' do
+ subject { described_class.generic_package_version_regex }
+
+ it { is_expected.to match('1.2.3') }
+ it { is_expected.to match('1.3.350') }
+ it { is_expected.not_to match('1.3.350-20201230123456') }
+ it { is_expected.not_to match('..1.2.3') }
+ it { is_expected.not_to match(' 1.2.3') }
+ it { is_expected.not_to match("1.2.3 \r\t") }
+ it { is_expected.not_to match("\r\t 1.2.3") }
+ it { is_expected.not_to match('1.2.3-4/../../') }
+ it { is_expected.not_to match('1.2.3-4%2e%2e%') }
+ it { is_expected.not_to match('../../../../../1.2.3') }
+ it { is_expected.not_to match('%2e%2e%2f1.2.3') }
+ it { is_expected.not_to match('') }
+ end
end
diff --git a/spec/lib/gitlab/relative_positioning/item_context_spec.rb b/spec/lib/gitlab/relative_positioning/item_context_spec.rb
new file mode 100644
index 00000000000..daea8d8470d
--- /dev/null
+++ b/spec/lib/gitlab/relative_positioning/item_context_spec.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::RelativePositioning::ItemContext do
+ let_it_be(:default_user) { create_default(:user) }
+ let_it_be(:project, reload: true) { create(:project) }
+
+ def create_issue(pos)
+ create(:issue, project: project, relative_position: pos)
+ end
+
+ range = (101..107) # A deliberately small range, so we can test everything
+ indices = (0..).take(range.size)
+
+ let(:start) { ((range.first + range.last) / 2.0).floor }
+ let(:subjects) { issues.map { |i| described_class.new(i.reset, range) } }
+
+ # This allows us to refer to range in methods and examples
+ let_it_be(:full_range) { range }
+
+ context 'there are gaps at the start and end' do
+ let_it_be(:issues) { (range.first.succ..range.last.pred).map { |pos| create_issue(pos) } }
+
+ it 'is always possible to find a gap' do
+ expect(subjects)
+ .to all(have_attributes(find_next_gap_before: be_present, find_next_gap_after: be_present))
+ end
+
+ where(:index) { indices.reverse.drop(2) }
+
+ with_them do
+ subject { subjects[index] }
+
+ let(:positions) { subject.scoped_items.map(&:relative_position) }
+
+ it 'is possible to shift_right, which will consume the gap at the end' do
+ subject.shift_right
+
+ expect(subject.find_next_gap_after).not_to be_present
+
+ expect(positions).to all(be_between(range.first, range.last))
+ expect(positions).to eq(positions.uniq)
+ end
+
+ it 'is possible to create_space_right, which will move the gap to immediately after' do
+ subject.create_space_right
+
+ expect(subject.find_next_gap_after).to have_attributes(start_pos: subject.relative_position)
+ expect(positions).to all(be_between(range.first, range.last))
+ expect(positions).to eq(positions.uniq)
+ end
+
+ it 'is possible to shift_left, which will consume the gap at the start' do
+ subject.shift_left
+
+ expect(subject.find_next_gap_before).not_to be_present
+ expect(positions).to all(be_between(range.first, range.last))
+ expect(positions).to eq(positions.uniq)
+ end
+
+ it 'is possible to create_space_left, which will move the gap to immediately before' do
+ subject.create_space_left
+
+ expect(subject.find_next_gap_before).to have_attributes(start_pos: subject.relative_position)
+ expect(positions).to all(be_between(range.first, range.last))
+ expect(positions).to eq(positions.uniq)
+ end
+ end
+ end
+
+ context 'there is a gap of multiple spaces' do
+ let_it_be(:issues) { [range.first, range.last].map { |pos| create_issue(pos) } }
+
+ it 'is impossible to move the last element to the right' do
+ expect { subjects.last.shift_right }.to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
+ end
+
+ it 'is impossible to move the first element to the left' do
+ expect { subjects.first.shift_left }.to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
+ end
+
+ it 'is possible to move the last element to the left' do
+ subject = subjects.last
+
+ expect { subject.shift_left }.to change { subject.relative_position }.by(be < 0)
+ end
+
+ it 'is possible to move the first element to the right' do
+ subject = subjects.first
+
+ expect { subject.shift_right }.to change { subject.relative_position }.by(be > 0)
+ end
+
+ it 'is possible to find the gap from the right' do
+ gap = Gitlab::RelativePositioning::Gap.new(range.last, range.first)
+
+ expect(subjects.last).to have_attributes(
+ find_next_gap_before: eq(gap),
+ find_next_gap_after: be_nil
+ )
+ end
+
+ it 'is possible to find the gap from the left' do
+ gap = Gitlab::RelativePositioning::Gap.new(range.first, range.last)
+
+ expect(subjects.first).to have_attributes(
+ find_next_gap_before: be_nil,
+ find_next_gap_after: eq(gap)
+ )
+ end
+ end
+
+ context 'there are several free spaces' do
+ let_it_be(:issues) { range.select(&:even?).map { |pos| create_issue(pos) } }
+ let_it_be(:gaps) do
+ range.select(&:odd?).map do |pos|
+ rhs = pos.succ.clamp(range.first, range.last)
+ lhs = pos.pred.clamp(range.first, range.last)
+
+ {
+ before: Gitlab::RelativePositioning::Gap.new(rhs, lhs),
+ after: Gitlab::RelativePositioning::Gap.new(lhs, rhs)
+ }
+ end
+ end
+
+ def issue_at(position)
+ issues.find { |i| i.relative_position == position }
+ end
+
+ where(:current_pos) { range.select(&:even?) }
+
+ with_them do
+ let(:subject) { subjects.find { |s| s.relative_position == current_pos } }
+ let(:siblings) { subjects.reject { |s| s.relative_position == current_pos } }
+
+ def covered_by_range(pos)
+ full_range.cover?(pos) ? pos : nil
+ end
+
+ it 'finds the closest gap' do
+ closest_gap_before = gaps
+ .map { |gap| gap[:before] }
+ .select { |gap| gap.start_pos <= subject.relative_position }
+ .max_by { |gap| gap.start_pos }
+ closest_gap_after = gaps
+ .map { |gap| gap[:after] }
+ .select { |gap| gap.start_pos >= subject.relative_position }
+ .min_by { |gap| gap.start_pos }
+
+ expect(subject).to have_attributes(
+ find_next_gap_before: closest_gap_before,
+ find_next_gap_after: closest_gap_after
+ )
+ end
+
+ it 'finds the neighbours' do
+ expect(subject).to have_attributes(
+ lhs_neighbour: subject.neighbour(issue_at(subject.relative_position - 2)),
+ rhs_neighbour: subject.neighbour(issue_at(subject.relative_position + 2))
+ )
+ end
+
+ it 'finds the next relative_positions' do
+ expect(subject).to have_attributes(
+ prev_relative_position: covered_by_range(subject.relative_position - 2),
+ next_relative_position: covered_by_range(subject.relative_position + 2)
+ )
+ end
+
+ it 'finds the min/max positions' do
+ expect(subject).to have_attributes(
+ min_relative_position: issues.first.relative_position,
+ max_relative_position: issues.last.relative_position
+ )
+ end
+
+ it 'finds the min/max siblings' do
+ expect(subject).to have_attributes(
+ min_sibling: siblings.first,
+ max_sibling: siblings.last
+ )
+ end
+ end
+ end
+
+ context 'there is at least one free space' do
+ where(:free_space) { range.to_a }
+
+ with_them do
+ let(:issues) { range.reject { |x| x == free_space }.map { |p| create_issue(p) } }
+ let(:gap_rhs) { free_space.succ.clamp(range.first, range.last) }
+ let(:gap_lhs) { free_space.pred.clamp(range.first, range.last) }
+
+ it 'can always find a gap before if there is space to the left' do
+ expected_gap = Gitlab::RelativePositioning::Gap.new(gap_rhs, gap_lhs)
+
+ to_the_right_of_gap = subjects.select { |s| free_space < s.relative_position }
+
+ expect(to_the_right_of_gap)
+ .to all(have_attributes(find_next_gap_before: eq(expected_gap), find_next_gap_after: be_nil))
+ end
+
+ it 'can always find a gap after if there is space to the right' do
+ expected_gap = Gitlab::RelativePositioning::Gap.new(gap_lhs, gap_rhs)
+
+ to_the_left_of_gap = subjects.select { |s| s.relative_position < free_space }
+
+ expect(to_the_left_of_gap)
+ .to all(have_attributes(find_next_gap_before: be_nil, find_next_gap_after: eq(expected_gap)))
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/relative_positioning/mover_spec.rb b/spec/lib/gitlab/relative_positioning/mover_spec.rb
new file mode 100644
index 00000000000..c49230c2415
--- /dev/null
+++ b/spec/lib/gitlab/relative_positioning/mover_spec.rb
@@ -0,0 +1,487 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RelativePositioning::Mover do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:one_sibling, reload: true) { create(:project, creator: user, namespace: user.namespace) }
+ let_it_be(:one_free_space, reload: true) { create(:project, creator: user, namespace: user.namespace) }
+ let_it_be(:fully_occupied, reload: true) { create(:project, creator: user, namespace: user.namespace) }
+ let_it_be(:no_issues, reload: true) { create(:project, creator: user, namespace: user.namespace) }
+ let_it_be(:three_sibs, reload: true) { create(:project, creator: user, namespace: user.namespace) }
+
+ def create_issue(pos, parent = project)
+ create(:issue, author: user, project: parent, relative_position: pos)
+ end
+
+ range = (101..105)
+ indices = (0..).take(range.size)
+
+ let(:start) { ((range.first + range.last) / 2.0).floor }
+
+ subject { described_class.new(start, range) }
+
+ let_it_be(:full_set) do
+ range.each_with_index.map do |pos, i|
+ create(:issue, iid: i.succ, project: fully_occupied, relative_position: pos)
+ end
+ end
+
+ let_it_be(:sole_sibling) { create(:issue, iid: 1, project: one_sibling, relative_position: nil) }
+ let_it_be(:one_sibling_set) { [sole_sibling] }
+ let_it_be(:one_free_space_set) do
+ indices.drop(1).map { |iid| create(:issue, project: one_free_space, iid: iid.succ) }
+ end
+ let_it_be(:three_sibs_set) do
+ [1, 2, 3].map { |iid| create(:issue, iid: iid, project: three_sibs) }
+ end
+
+ def set_positions(positions)
+ vals = issues.zip(positions).map do |issue, pos|
+ issue.relative_position = pos
+ "(#{issue.id}, #{pos})"
+ end.join(', ')
+
+ Issue.connection.exec_query(<<~SQL, 'set-positions')
+ WITH cte(cte_id, new_pos) AS (
+ SELECT * FROM (VALUES #{vals}) as t (id, pos)
+ )
+ UPDATE issues SET relative_position = new_pos FROM cte WHERE id = cte_id
+ ;
+ SQL
+ end
+
+ def ids_in_position_order
+ project.issues.reorder(:relative_position).pluck(:id)
+ end
+
+ def relative_positions
+ project.issues.pluck(:relative_position)
+ end
+
+ describe '#move_to_end' do
+ def max_position
+ project.issues.maximum(:relative_position)
+ end
+
+ def move_to_end(issue)
+ subject.move_to_end(issue)
+ issue.save!
+ end
+
+ shared_examples 'able to place a new item at the end' do
+ it 'can place any new item' do
+ existing_issues = ids_in_position_order
+ new_item = create_issue(nil)
+
+ expect do
+ move_to_end(new_item)
+ end.to change { project.issues.pluck(:id, :relative_position) }
+
+ expect(new_item.relative_position).to eq(max_position)
+ expect(relative_positions).to all(be_between(range.first, range.last))
+ expect(ids_in_position_order).to eq(existing_issues + [new_item.id])
+ end
+ end
+
+ shared_examples 'able to move existing items to the end' do
+ it 'can move any existing item' do
+ issues = project.issues.reorder(:relative_position).to_a
+ issue = issues[index]
+ other_issues = issues.reject { |i| i == issue }
+ expect(relative_positions).to all(be_between(range.first, range.last))
+
+ if issues.last == issue
+ move_to_end(issue) # May not change the positions
+ else
+ expect do
+ move_to_end(issue)
+ end.to change { project.issues.pluck(:id, :relative_position) }
+ end
+
+ project.reset
+
+ expect(relative_positions).to all(be_between(range.first, range.last))
+ expect(issue.relative_position).to eq(max_position)
+ expect(ids_in_position_order).to eq(other_issues.map(&:id) + [issue.id])
+ end
+ end
+
+ context 'all positions are taken' do
+ let(:issues) { full_set }
+ let(:project) { fully_occupied }
+
+ it 'raises an error when placing a new item' do
+ new_item = create_issue(nil)
+
+ expect { subject.move_to_end(new_item) }.to raise_error(RelativePositioning::NoSpaceLeft)
+ end
+
+ where(:index) { indices }
+
+ with_them do
+ it_behaves_like 'able to move existing items to the end'
+ end
+ end
+
+ context 'there are no siblings' do
+ let(:issues) { [] }
+ let(:project) { no_issues }
+
+ it_behaves_like 'able to place a new item at the end'
+ end
+
+ context 'there is only one sibling' do
+ where(:pos) { range.to_a }
+
+ with_them do
+ let(:issues) { one_sibling_set }
+ let(:project) { one_sibling }
+ let(:index) { 0 }
+
+ before do
+ sole_sibling.reset.update!(relative_position: pos)
+ end
+
+ it_behaves_like 'able to place a new item at the end'
+
+ it_behaves_like 'able to move existing items to the end'
+ end
+ end
+
+ context 'at least one position is free' do
+ where(:free_space, :index) do
+ is = indices.take(range.size - 1)
+
+ range.to_a.product(is)
+ end
+
+ with_them do
+ let(:issues) { one_free_space_set }
+ let(:project) { one_free_space }
+
+ before do
+ positions = range.reject { |x| x == free_space }
+ set_positions(positions)
+ end
+
+ it_behaves_like 'able to place a new item at the end'
+
+ it_behaves_like 'able to move existing items to the end'
+ end
+ end
+ end
+
+ describe '#move_to_start' do
+ def min_position
+ project.issues.minimum(:relative_position)
+ end
+
+ def move_to_start(issue)
+ subject.move_to_start(issue)
+ issue.save!
+ end
+
+ shared_examples 'able to place a new item at the start' do
+ it 'can place any new item' do
+ existing_issues = ids_in_position_order
+ new_item = create_issue(nil)
+
+ expect do
+ move_to_start(new_item)
+ end.to change { project.issues.pluck(:id, :relative_position) }
+
+ expect(relative_positions).to all(be_between(range.first, range.last))
+ expect(new_item.relative_position).to eq(min_position)
+ expect(ids_in_position_order).to eq([new_item.id] + existing_issues)
+ end
+ end
+
+ shared_examples 'able to move existing items to the start' do
+ it 'can move any existing item' do
+ issues = project.issues.reorder(:relative_position).to_a
+ issue = issues[index]
+ other_issues = issues.reject { |i| i == issue }
+ expect(relative_positions).to all(be_between(range.first, range.last))
+
+ if issues.first == issue
+ move_to_start(issue) # May not change the positions
+ else
+ expect do
+ move_to_start(issue)
+ end.to change { project.issues.pluck(:id, :relative_position) }
+ end
+
+ project.reset
+
+ expect(relative_positions).to all(be_between(range.first, range.last))
+ expect(issue.relative_position).to eq(min_position)
+ expect(ids_in_position_order).to eq([issue.id] + other_issues.map(&:id))
+ end
+ end
+
+ context 'all positions are taken' do
+ let(:issues) { full_set }
+ let(:project) { fully_occupied }
+
+ it 'raises an error when placing a new item' do
+ new_item = create(:issue, project: project, relative_position: nil)
+
+ expect { subject.move_to_start(new_item) }.to raise_error(RelativePositioning::NoSpaceLeft)
+ end
+
+ where(:index) { indices }
+
+ with_them do
+ it_behaves_like 'able to move existing items to the start'
+ end
+ end
+
+ context 'there are no siblings' do
+ let(:project) { no_issues }
+ let(:issues) { [] }
+
+ it_behaves_like 'able to place a new item at the start'
+ end
+
+ context 'there is only one sibling' do
+ where(:pos) { range.to_a }
+
+ with_them do
+ let(:issues) { one_sibling_set }
+ let(:project) { one_sibling }
+ let(:index) { 0 }
+
+ before do
+ sole_sibling.reset.update!(relative_position: pos)
+ end
+
+ it_behaves_like 'able to place a new item at the start'
+
+ it_behaves_like 'able to move existing items to the start'
+ end
+ end
+
+ context 'at least one position is free' do
+ where(:free_space, :index) do
+ range.to_a.product((0..).take(range.size - 1).to_a)
+ end
+
+ with_them do
+ let(:issues) { one_free_space_set }
+ let(:project) { one_free_space }
+
+ before do
+ set_positions(range.reject { |x| x == free_space })
+ end
+
+ it_behaves_like 'able to place a new item at the start'
+
+ it_behaves_like 'able to move existing items to the start'
+ end
+ end
+ end
+
+ describe '#move' do
+ shared_examples 'able to move a new item' do
+ let(:other_issues) { project.issues.reorder(relative_position: :asc).to_a }
+ let!(:previous_order) { other_issues.map(&:id) }
+
+ it 'can place any new item betwen two others' do
+ new_item = create_issue(nil)
+
+ subject.move(new_item, lhs, rhs)
+ new_item.save!
+ lhs.reset
+ rhs.reset
+
+ expect(new_item.relative_position).to be_between(range.first, range.last)
+ expect(new_item.relative_position).to be_between(lhs.relative_position, rhs.relative_position)
+
+ ids = project.issues.reorder(:relative_position).pluck(:id).reject { |id| id == new_item.id }
+ expect(ids).to eq(previous_order)
+ end
+
+ it 'can place any new item after another' do
+ new_item = create_issue(nil)
+
+ subject.move(new_item, lhs, nil)
+ new_item.save!
+ lhs.reset
+
+ expect(new_item.relative_position).to be_between(range.first, range.last)
+ expect(new_item.relative_position).to be > lhs.relative_position
+
+ ids = project.issues.reorder(:relative_position).pluck(:id).reject { |id| id == new_item.id }
+ expect(ids).to eq(previous_order)
+ end
+
+ it 'can place any new item before another' do
+ new_item = create_issue(nil)
+
+ subject.move(new_item, nil, rhs)
+ new_item.save!
+ rhs.reset
+
+ expect(new_item.relative_position).to be_between(range.first, range.last)
+ expect(new_item.relative_position).to be < rhs.relative_position
+
+ ids = project.issues.reorder(:relative_position).pluck(:id).reject { |id| id == new_item.id }
+ expect(ids).to eq(previous_order)
+ end
+ end
+
+ shared_examples 'able to move an existing item' do
+ let(:all_issues) { project.issues.reorder(:relative_position).to_a }
+ let(:item) { all_issues[index] }
+ let(:positions) { project.reset.issues.pluck(:relative_position) }
+ let(:other_issues) { all_issues.reject { |i| i == item } }
+ let!(:previous_order) { other_issues.map(&:id) }
+ let(:new_order) do
+ project.issues.where.not(id: item.id).reorder(:relative_position).pluck(:id)
+ end
+
+ it 'can place any item betwen two others' do
+ subject.move(item, lhs, rhs)
+ item.save!
+ lhs.reset
+ rhs.reset
+
+ expect(positions).to all(be_between(range.first, range.last))
+ expect(positions).to match_array(positions.uniq)
+ expect(item.relative_position).to be_between(lhs.relative_position, rhs.relative_position)
+
+ expect(new_order).to eq(previous_order)
+ end
+
+ def sequence(expected_sequence)
+ range = (expected_sequence.first.relative_position..expected_sequence.last.relative_position)
+
+ project.issues.reorder(:relative_position).where(relative_position: range)
+ end
+
+ it 'can place any item after another' do
+ subject.move(item, lhs, nil)
+ item.save!
+ lhs.reset
+
+ expect(positions).to all(be_between(range.first, range.last))
+ expect(positions).to match_array(positions.uniq)
+ expect(item.relative_position).to be >= lhs.relative_position
+
+ expected_sequence = [lhs, item].uniq
+
+ expect(sequence(expected_sequence)).to eq(expected_sequence)
+
+ expect(new_order).to eq(previous_order)
+ end
+
+ it 'can place any item before another' do
+ subject.move(item, nil, rhs)
+ item.save!
+ rhs.reset
+
+ expect(positions).to all(be_between(range.first, range.last))
+ expect(positions).to match_array(positions.uniq)
+ expect(item.relative_position).to be <= rhs.relative_position
+
+ expected_sequence = [item, rhs].uniq
+
+ expect(sequence(expected_sequence)).to eq(expected_sequence)
+
+ expect(new_order).to eq(previous_order)
+ end
+ end
+
+ context 'all positions are taken' do
+ let(:issues) { full_set }
+ let(:project) { fully_occupied }
+
+ where(:idx_a, :idx_b) do
+ indices.product(indices).select { |a, b| a < b }
+ end
+
+ with_them do
+ let(:lhs) { issues[idx_a].reset }
+ let(:rhs) { issues[idx_b].reset }
+
+ it 'raises an error when placing a new item anywhere' do
+ new_item = create_issue(nil)
+
+ expect { subject.move(new_item, lhs, rhs) }
+ .to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
+
+ expect { subject.move(new_item, nil, rhs) }
+ .to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
+
+ expect { subject.move(new_item, lhs, nil) }
+ .to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
+ end
+
+ where(:index) { indices }
+
+ with_them do
+ it_behaves_like 'able to move an existing item'
+ end
+ end
+ end
+
+ context 'there are no siblings' do
+ let(:project) { no_issues }
+
+ it 'raises an ArgumentError when both first and last are nil' do
+ new_item = create_issue(nil)
+
+ expect { subject.move(new_item, nil, nil) }.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'there are a couple of siblings' do
+ where(:pos_movable, :pos_a, :pos_b) do
+ xs = range.to_a
+
+ xs.product(xs).product(xs).map(&:flatten)
+ .select { |vals| vals == vals.uniq && vals[1] < vals[2] }
+ end
+
+ with_them do
+ let(:issues) { three_sibs_set }
+ let(:project) { three_sibs }
+ let(:index) { 0 }
+ let(:lhs) { issues[1] }
+ let(:rhs) { issues[2] }
+
+ before do
+ set_positions([pos_movable, pos_a, pos_b])
+ end
+
+ it_behaves_like 'able to move a new item'
+ it_behaves_like 'able to move an existing item'
+ end
+ end
+
+ context 'at least one position is free' do
+ where(:free_space, :index, :pos_a, :pos_b) do
+ is = indices.reverse.drop(1)
+
+ range.to_a.product(is).product(is).product(is)
+ .map(&:flatten)
+ .select { |_, _, a, b| a < b }
+ end
+
+ with_them do
+ let(:issues) { one_free_space_set }
+ let(:project) { one_free_space }
+ let(:lhs) { issues[pos_a] }
+ let(:rhs) { issues[pos_b] }
+
+ before do
+ set_positions(range.reject { |x| x == free_space })
+ end
+
+ it_behaves_like 'able to move a new item'
+ it_behaves_like 'able to move an existing item'
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/relative_positioning/range_spec.rb b/spec/lib/gitlab/relative_positioning/range_spec.rb
new file mode 100644
index 00000000000..c3386336493
--- /dev/null
+++ b/spec/lib/gitlab/relative_positioning/range_spec.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::RelativePositioning::Range do
+ item_a = OpenStruct.new(relative_position: 100, object: :x, positioned?: true)
+ item_b = OpenStruct.new(relative_position: 200, object: :y, positioned?: true)
+
+ before do
+ allow(item_a).to receive(:lhs_neighbour) { nil }
+ allow(item_a).to receive(:rhs_neighbour) { item_b }
+
+ allow(item_b).to receive(:lhs_neighbour) { item_a }
+ allow(item_b).to receive(:rhs_neighbour) { nil }
+ end
+
+ describe 'RelativePositioning.range' do
+ it 'raises if lhs and rhs are nil' do
+ expect { Gitlab::RelativePositioning.range(nil, nil) }.to raise_error(ArgumentError)
+ end
+
+ it 'raises an error if there is no extent' do
+ expect { Gitlab::RelativePositioning.range(item_a, item_a) }.to raise_error(ArgumentError)
+ end
+
+ it 'constructs a closed range when both termini are provided' do
+ range = Gitlab::RelativePositioning.range(item_a, item_b)
+ expect(range).to be_a_kind_of(Gitlab::RelativePositioning::Range)
+ expect(range).to be_a_kind_of(Gitlab::RelativePositioning::ClosedRange)
+ end
+
+ it 'constructs a starting-from range when only the LHS is provided' do
+ range = Gitlab::RelativePositioning.range(item_a, nil)
+ expect(range).to be_a_kind_of(Gitlab::RelativePositioning::Range)
+ expect(range).to be_a_kind_of(Gitlab::RelativePositioning::StartingFrom)
+ end
+
+ it 'constructs an ending-at range when only the RHS is provided' do
+ range = Gitlab::RelativePositioning.range(nil, item_b)
+ expect(range).to be_a_kind_of(Gitlab::RelativePositioning::Range)
+ expect(range).to be_a_kind_of(Gitlab::RelativePositioning::EndingAt)
+ end
+ end
+
+ it 'infers neighbours correctly' do
+ starting_at_a = Gitlab::RelativePositioning.range(item_a, nil)
+ ending_at_b = Gitlab::RelativePositioning.range(nil, item_b)
+
+ expect(starting_at_a).to eq(ending_at_b)
+ end
+
+ describe '#open_on_left?' do
+ where(:lhs, :rhs, :expected_result) do
+ [
+ [item_a, item_b, false],
+ [item_a, nil, false],
+ [nil, item_b, false],
+ [item_b, nil, false],
+ [nil, item_a, true]
+ ]
+ end
+
+ with_them do
+ it 'is true if there is no LHS terminus' do
+ range = Gitlab::RelativePositioning.range(lhs, rhs)
+
+ expect(range.open_on_left?).to be(expected_result)
+ end
+ end
+ end
+
+ describe '#open_on_right?' do
+ where(:lhs, :rhs, :expected_result) do
+ [
+ [item_a, item_b, false],
+ [item_a, nil, false],
+ [nil, item_b, false],
+ [item_b, nil, true],
+ [nil, item_a, false]
+ ]
+ end
+
+ with_them do
+ it 'is true if there is no RHS terminus' do
+ range = Gitlab::RelativePositioning.range(lhs, rhs)
+
+ expect(range.open_on_right?).to be(expected_result)
+ end
+ end
+ end
+
+ describe '#cover?' do
+ item_c = OpenStruct.new(relative_position: 150, object: :z, positioned?: true)
+ item_d = OpenStruct.new(relative_position: 050, object: :w, positioned?: true)
+ item_e = OpenStruct.new(relative_position: 250, object: :r, positioned?: true)
+ item_f = OpenStruct.new(positioned?: false)
+ item_ax = OpenStruct.new(relative_position: 100, object: :not_x, positioned?: true)
+ item_bx = OpenStruct.new(relative_position: 200, object: :not_y, positioned?: true)
+
+ where(:lhs, :rhs, :item, :expected_result) do
+ [
+ [item_a, item_b, item_a, true],
+ [item_a, item_b, item_b, true],
+ [item_a, item_b, item_c, true],
+ [item_a, item_b, item_d, false],
+ [item_a, item_b, item_e, false],
+ [item_a, item_b, item_ax, false],
+ [item_a, item_b, item_bx, false],
+ [item_a, item_b, item_f, false],
+ [item_a, item_b, nil, false],
+
+ [nil, item_b, item_a, true],
+ [nil, item_b, item_b, true],
+ [nil, item_b, item_c, true],
+ [nil, item_b, item_d, false],
+ [nil, item_b, item_e, false],
+ [nil, item_b, item_ax, false],
+ [nil, item_b, item_bx, false],
+ [nil, item_b, item_f, false],
+ [nil, item_b, nil, false],
+
+ [item_a, nil, item_a, true],
+ [item_a, nil, item_b, true],
+ [item_a, nil, item_c, true],
+ [item_a, nil, item_d, false],
+ [item_a, nil, item_e, false],
+ [item_a, nil, item_ax, false],
+ [item_a, nil, item_bx, false],
+ [item_a, nil, item_f, false],
+ [item_a, nil, nil, false],
+
+ [nil, item_a, item_a, true],
+ [nil, item_a, item_b, false],
+ [nil, item_a, item_c, false],
+ [nil, item_a, item_d, true],
+ [nil, item_a, item_e, false],
+ [nil, item_a, item_ax, false],
+ [nil, item_a, item_bx, false],
+ [nil, item_a, item_f, false],
+ [nil, item_a, nil, false],
+
+ [item_b, nil, item_a, false],
+ [item_b, nil, item_b, true],
+ [item_b, nil, item_c, false],
+ [item_b, nil, item_d, false],
+ [item_b, nil, item_e, true],
+ [item_b, nil, item_ax, false],
+ [item_b, nil, item_bx, false],
+ [item_b, nil, item_f, false],
+ [item_b, nil, nil, false]
+ ]
+ end
+
+ with_them do
+ it 'is true when the object is within the bounds of the range' do
+ range = Gitlab::RelativePositioning.range(lhs, rhs)
+
+ expect(range.cover?(item)).to be(expected_result)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/repository_cache_adapter_spec.rb b/spec/lib/gitlab/repository_cache_adapter_spec.rb
index c9ad79234d3..4c57665b41f 100644
--- a/spec/lib/gitlab/repository_cache_adapter_spec.rb
+++ b/spec/lib/gitlab/repository_cache_adapter_spec.rb
@@ -302,7 +302,7 @@ RSpec.describe Gitlab::RepositoryCacheAdapter do
it 'does not expire caches for non-existent methods' do
expect(cache).not_to receive(:expire).with(:nonexistent)
- expect(Rails.logger).to(
+ expect(Gitlab::AppLogger).to(
receive(:error).with("Requested to expire non-existent method 'nonexistent' for Repository"))
repository.expire_method_caches(%i(nonexistent))
diff --git a/spec/lib/gitlab/robots_txt/parser_spec.rb b/spec/lib/gitlab/robots_txt/parser_spec.rb
new file mode 100644
index 00000000000..bb88003ce20
--- /dev/null
+++ b/spec/lib/gitlab/robots_txt/parser_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rspec-parameterized'
+
+RSpec.describe Gitlab::RobotsTxt::Parser do
+ describe '#disallowed?' do
+ subject { described_class.new(content).disallowed?(path) }
+
+ context 'a simple robots.txt file' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:content) do
+ <<~TXT
+ User-Agent: *
+ Disallow: /autocomplete/users
+ Disallow: /search
+ Disallow: /api
+ TXT
+ end
+
+ where(:path, :result) do
+ '/autocomplete/users' | true
+ '/autocomplete/users/a.html' | true
+ '/search' | true
+ '/search.html' | true
+ '/api' | true
+ '/api/grapql' | true
+ '/api/index.html' | true
+ '/projects' | false
+ end
+
+ with_them do
+ it { is_expected.to eq(result), "#{path} expected to be #{result}" }
+ end
+ end
+
+ context 'robots.txt file with wildcard' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:content) do
+ <<~TXT
+ User-Agent: *
+ Disallow: /search
+
+ User-Agent: *
+ Disallow: /*/*.git
+ Disallow: /*/archive/
+ Disallow: /*/repository/archive*
+ TXT
+ end
+
+ where(:path, :result) do
+ '/search' | true
+ '/namespace/project.git' | true
+ '/project/archive/' | true
+ '/project/archive/file.gz' | true
+ '/project/repository/archive' | true
+ '/project/repository/archive.gz' | true
+ '/project/repository/archive/file.gz' | true
+ '/projects' | false
+ '/git' | false
+ '/projects/git' | false
+ end
+
+ with_them do
+ it { is_expected.to eq(result), "#{path} expected to be #{result}" }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/search/recent_issues_spec.rb b/spec/lib/gitlab/search/recent_issues_spec.rb
new file mode 100644
index 00000000000..19a41d2aa38
--- /dev/null
+++ b/spec/lib/gitlab/search/recent_issues_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Gitlab::Search::RecentIssues do
+ def create_item(content:, project:)
+ create(:issue, title: content, project: project)
+ end
+
+ it_behaves_like 'search recent items'
+end
diff --git a/spec/lib/gitlab/search/recent_merge_requests_spec.rb b/spec/lib/gitlab/search/recent_merge_requests_spec.rb
new file mode 100644
index 00000000000..c6678ce0342
--- /dev/null
+++ b/spec/lib/gitlab/search/recent_merge_requests_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Gitlab::Search::RecentMergeRequests do
+ def create_item(content:, project:)
+ create(:merge_request, :unique_branches, title: content, target_project: project, source_project: project)
+ end
+
+ it_behaves_like 'search recent items'
+end
diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb
index 61fa61566cd..b4cf6a568b4 100644
--- a/spec/lib/gitlab/search_results_spec.rb
+++ b/spec/lib/gitlab/search_results_spec.rb
@@ -6,16 +6,14 @@ RSpec.describe Gitlab::SearchResults do
include ProjectForksHelper
include SearchHelpers
- let(:user) { create(:user) }
- let!(:project) { create(:project, name: 'foo') }
- let!(:issue) { create(:issue, project: project, title: 'foo') }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, name: 'foo') }
+ let_it_be(:issue) { create(:issue, project: project, title: 'foo') }
+ let_it_be(:milestone) { create(:milestone, project: project, title: 'foo') }
+ let(:merge_request) { create(:merge_request, source_project: project, title: 'foo') }
+ let(:filters) { {} }
- let!(:merge_request) do
- create(:merge_request, source_project: project, title: 'foo')
- end
-
- let!(:milestone) { create(:milestone, project: project, title: 'foo') }
- let(:results) { described_class.new(user, Project.all, 'foo') }
+ subject(:results) { described_class.new(user, 'foo', Project.order(:id), filters: filters) }
context 'as a user with access' do
before do
@@ -108,10 +106,10 @@ RSpec.describe Gitlab::SearchResults do
describe '#limited_issues_count' do
it 'runs single SQL query to get the limited amount of issues' do
- create(:milestone, project: project, title: 'foo2')
+ create(:issue, project: project, title: 'foo2')
expect(results).to receive(:issues).with(public_only: true).and_call_original
- expect(results).not_to receive(:issues).with(no_args).and_call_original
+ expect(results).not_to receive(:issues).with(no_args)
expect(results.limited_issues_count).to eq(1)
end
@@ -133,7 +131,7 @@ RSpec.describe Gitlab::SearchResults do
forked_project = fork_project(project, user)
merge_request_2 = create(:merge_request, target_project: project, source_project: forked_project, title: 'foo')
- results = described_class.new(user, Project.where(id: forked_project.id), 'foo')
+ results = described_class.new(user, 'foo', Project.where(id: forked_project.id))
expect(results.objects('merge_requests')).to include merge_request_2
end
@@ -152,6 +150,15 @@ RSpec.describe Gitlab::SearchResults do
results.objects('merge_requests')
end
+
+ context 'filtering' do
+ let!(:opened_result) { create(:merge_request, :opened, source_project: project, title: 'foo opened') }
+ let!(:closed_result) { create(:merge_request, :closed, source_project: project, title: 'foo closed') }
+ let(:scope) { 'merge_requests' }
+ let(:query) { 'foo' }
+
+ include_examples 'search results filtered by state'
+ end
end
describe '#issues' do
@@ -168,6 +175,15 @@ RSpec.describe Gitlab::SearchResults do
results.objects('issues')
end
+
+ context 'filtering' do
+ let(:scope) { 'issues' }
+
+ let_it_be(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') }
+ let_it_be(:opened_result) { create(:issue, :opened, project: project, title: 'foo open') }
+
+ include_examples 'search results filtered by state'
+ end
end
describe '#users' do
@@ -214,7 +230,7 @@ RSpec.describe Gitlab::SearchResults do
let!(:security_issue_5) { create(:issue, :confidential, project: project_4, title: 'Security issue 5') }
it 'does not list confidential issues for non project members' do
- results = described_class.new(non_member, limit_projects, query)
+ results = described_class.new(non_member, query, limit_projects)
issues = results.objects('issues')
expect(issues).to include issue
@@ -230,7 +246,7 @@ RSpec.describe Gitlab::SearchResults do
project_1.add_guest(member)
project_2.add_guest(member)
- results = described_class.new(member, limit_projects, query)
+ results = described_class.new(member, query, limit_projects)
issues = results.objects('issues')
expect(issues).to include issue
@@ -243,7 +259,7 @@ RSpec.describe Gitlab::SearchResults do
end
it 'lists confidential issues for author' do
- results = described_class.new(author, limit_projects, query)
+ results = described_class.new(author, query, limit_projects)
issues = results.objects('issues')
expect(issues).to include issue
@@ -256,7 +272,7 @@ RSpec.describe Gitlab::SearchResults do
end
it 'lists confidential issues for assignee' do
- results = described_class.new(assignee, limit_projects, query)
+ results = described_class.new(assignee, query, limit_projects)
issues = results.objects('issues')
expect(issues).to include issue
@@ -272,7 +288,7 @@ RSpec.describe Gitlab::SearchResults do
project_1.add_developer(member)
project_2.add_developer(member)
- results = described_class.new(member, limit_projects, query)
+ results = described_class.new(member, query, limit_projects)
issues = results.objects('issues')
expect(issues).to include issue
@@ -285,7 +301,7 @@ RSpec.describe Gitlab::SearchResults do
end
it 'lists all issues for admin' do
- results = described_class.new(admin, limit_projects, query)
+ results = described_class.new(admin, query, limit_projects)
issues = results.objects('issues')
expect(issues).to include issue
@@ -323,7 +339,7 @@ RSpec.describe Gitlab::SearchResults do
# Global search scope takes user authorized projects, internal projects and public projects.
limit_projects = ProjectsFinder.new(current_user: user).execute
- milestones = described_class.new(user, limit_projects, 'milestone').objects('milestones')
+ milestones = described_class.new(user, 'milestone', limit_projects).objects('milestones')
expect(milestones).to match_array([milestone_1, milestone_2, milestone_3])
end
diff --git a/spec/lib/gitlab/sidekiq_daemon/memory_killer_spec.rb b/spec/lib/gitlab/sidekiq_daemon/memory_killer_spec.rb
index 0ff2dbb234a..4a952a2040a 100644
--- a/spec/lib/gitlab/sidekiq_daemon/memory_killer_spec.rb
+++ b/spec/lib/gitlab/sidekiq_daemon/memory_killer_spec.rb
@@ -49,7 +49,7 @@ RSpec.describe Gitlab::SidekiqDaemon::MemoryKiller do
expect { subject }.not_to raise_exception
end
- it 'logs exception message once and raise execption and log stop message' do
+ 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,
@@ -68,7 +68,7 @@ RSpec.describe Gitlab::SidekiqDaemon::MemoryKiller do
pid: pid,
message: 'Stopping Gitlab::SidekiqDaemon::MemoryKiller Daemon')
- expect { subject }.to raise_exception
+ expect { subject }.to raise_exception(Exception, 'My Exception')
end
it 'logs stop message once' do
@@ -402,12 +402,14 @@ RSpec.describe Gitlab::SidekiqDaemon::MemoryKiller do
subject { memory_killer.send(:rss_increase_by_jobs) }
it 'adds up individual rss_increase_by_job' do
+ allow(Gitlab::SidekiqDaemon::Monitor).to receive_message_chain(:instance, :jobs_mutex, :synchronize).and_yield
expect(Gitlab::SidekiqDaemon::Monitor).to receive_message_chain(:instance, :jobs).and_return(running_jobs)
expect(memory_killer).to receive(:rss_increase_by_job).and_return(11, 22)
expect(subject).to eq(33)
end
it 'return 0 if no job' do
+ allow(Gitlab::SidekiqDaemon::Monitor).to receive_message_chain(:instance, :jobs_mutex, :synchronize).and_yield
expect(Gitlab::SidekiqDaemon::Monitor).to receive_message_chain(:instance, :jobs).and_return({})
expect(subject).to eq(0)
end
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/none_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/none_spec.rb
new file mode 100644
index 00000000000..3250c7cfa31
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/none_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::None do
+ let(:fake_duplicate_job) do
+ instance_double(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob)
+ end
+
+ subject(:strategy) { described_class.new(fake_duplicate_job) }
+
+ describe '#schedule' do
+ it 'yields without checking for duplicates', :aggregate_failures do
+ expect(fake_duplicate_job).not_to receive(:scheduled?)
+ expect(fake_duplicate_job).not_to receive(:duplicate?)
+ expect(fake_duplicate_job).not_to receive(:check!)
+
+ expect { |b| strategy.schedule({}, &b) }.to yield_control
+ end
+ end
+
+ describe '#perform' do
+ it 'does not delete any locks before executing', :aggregate_failures do
+ expect(fake_duplicate_job).not_to receive(:delete!)
+
+ expect { |b| strategy.perform({}, &b) }.to yield_control
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb
index 77d760d1ae3..10b18052e9a 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'timecop'
RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecuting do
let(:fake_duplicate_job) do
@@ -77,7 +76,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecut
context 'scheduled in the future' do
it 'adds the jid of the existing job to the job hash' do
- Timecop.freeze do
+ freeze_time do
allow(fake_duplicate_job).to receive(:scheduled?).twice.and_return(true)
allow(fake_duplicate_job).to receive(:scheduled_at).and_return(Time.now + time_diff)
allow(fake_duplicate_job).to receive(:options).and_return({ including_scheduled: true })
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb
index 5d37e3cb1ae..84856238aab 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb
@@ -8,6 +8,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies do
expect(described_class.for(:until_executing)).to eq(described_class::UntilExecuting)
end
+ it 'returns the right class for `none`' do
+ expect(described_class.for(:none)).to eq(described_class::None)
+ end
+
it 'raises an UnknownStrategyError when passing an unknown key' do
expect { described_class.for(:unknown) }.to raise_error(described_class::UnknownStrategyError)
end
diff --git a/spec/lib/gitlab/sql/except_spec.rb b/spec/lib/gitlab/sql/except_spec.rb
new file mode 100644
index 00000000000..a3d6990ee2e
--- /dev/null
+++ b/spec/lib/gitlab/sql/except_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SQL::Except do
+ it_behaves_like 'SQL set operator', 'EXCEPT'
+end
diff --git a/spec/lib/gitlab/sql/intersect_spec.rb b/spec/lib/gitlab/sql/intersect_spec.rb
new file mode 100644
index 00000000000..cf076796712
--- /dev/null
+++ b/spec/lib/gitlab/sql/intersect_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SQL::Intersect do
+ it_behaves_like 'SQL set operator', 'INTERSECT'
+end
diff --git a/spec/lib/gitlab/sql/union_spec.rb b/spec/lib/gitlab/sql/union_spec.rb
index c8be83c093d..a41551e25bf 100644
--- a/spec/lib/gitlab/sql/union_spec.rb
+++ b/spec/lib/gitlab/sql/union_spec.rb
@@ -3,40 +3,5 @@
require 'spec_helper'
RSpec.describe Gitlab::SQL::Union do
- let(:relation_1) { User.where(email: 'alice@example.com').select(:id) }
- let(:relation_2) { User.where(email: 'bob@example.com').select(:id) }
-
- def to_sql(relation)
- relation.reorder(nil).to_sql
- end
-
- describe '#to_sql' do
- it 'returns a String joining relations together using a UNION' do
- union = described_class.new([relation_1, relation_2])
-
- expect(union.to_sql).to eq("(#{to_sql(relation_1)})\nUNION\n(#{to_sql(relation_2)})")
- end
-
- it 'skips Model.none segements' do
- empty_relation = User.none
- union = described_class.new([empty_relation, relation_1, relation_2])
-
- expect {User.where("users.id IN (#{union.to_sql})").to_a}.not_to raise_error
- expect(union.to_sql).to eq("(#{to_sql(relation_1)})\nUNION\n(#{to_sql(relation_2)})")
- end
-
- it 'uses UNION ALL when removing duplicates is disabled' do
- union = described_class
- .new([relation_1, relation_2], remove_duplicates: false)
-
- expect(union.to_sql).to include('UNION ALL')
- end
-
- it 'returns `NULL` if all relations are empty' do
- empty_relation = User.none
- union = described_class.new([empty_relation, empty_relation])
-
- expect(union.to_sql).to eq('NULL')
- end
- end
+ it_behaves_like 'SQL set operator', 'UNION'
end
diff --git a/spec/lib/gitlab/static_site_editor/config/file_config_spec.rb b/spec/lib/gitlab/static_site_editor/config/file_config_spec.rb
new file mode 100644
index 00000000000..594425c2dab
--- /dev/null
+++ b/spec/lib/gitlab/static_site_editor/config/file_config_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::StaticSiteEditor::Config::FileConfig do
+ subject(:config) { described_class.new }
+
+ describe '#data' do
+ subject { config.data }
+
+ it 'returns hardcoded data for now' do
+ is_expected.to match(static_site_generator: 'middleman')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/static_site_editor/config_spec.rb b/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb
index 56cdb573785..3433a54be9c 100644
--- a/spec/lib/gitlab/static_site_editor/config_spec.rb
+++ b/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe Gitlab::StaticSiteEditor::Config do
- subject(:config) { described_class.new(repository, ref, file_path, return_url) }
+RSpec.describe Gitlab::StaticSiteEditor::Config::GeneratedConfig do
+ subject(:config) { described_class.new(repository, ref, path, return_url) }
let_it_be(:namespace) { create(:namespace, name: 'namespace') }
let_it_be(:root_group) { create(:group, name: 'group') }
@@ -13,24 +13,26 @@ RSpec.describe Gitlab::StaticSiteEditor::Config do
let_it_be(:repository) { project.repository }
let(:ref) { 'master' }
- let(:file_path) { 'README.md' }
+ let(:path) { 'README.md' }
let(:return_url) { 'http://example.com' }
- describe '#payload' do
- subject { config.payload }
+ describe '#data' do
+ subject { config.data }
it 'returns data for the frontend component' do
- is_expected.to eq(
- branch: 'master',
- commit_id: repository.commit.id,
- namespace: 'namespace',
- path: 'README.md',
- project: 'project',
- project_id: project.id,
- return_url: 'http://example.com',
- is_supported_content: 'true',
- base_url: '/namespace/project/-/sse/master%2FREADME.md'
- )
+ is_expected
+ .to match({
+ branch: 'master',
+ commit_id: repository.commit.id,
+ namespace: 'namespace',
+ path: 'README.md',
+ project: 'project',
+ project_id: project.id,
+ return_url: 'http://example.com',
+ is_supported_content: 'true',
+ base_url: '/namespace/project/-/sse/master%2FREADME.md',
+ merge_requests_illustration_path: %r{illustrations/merge_requests}
+ })
end
context 'when namespace is a subgroup' do
@@ -49,7 +51,7 @@ RSpec.describe Gitlab::StaticSiteEditor::Config do
before do
repository.create_file(
project.creator,
- file_path,
+ path,
'',
message: 'message',
branch_name: 'master'
@@ -57,7 +59,7 @@ RSpec.describe Gitlab::StaticSiteEditor::Config do
end
context 'when feature flag is enabled' do
- let(:file_path) { 'FEATURE_ON.md.erb' }
+ let(:path) { 'FEATURE_ON.md.erb' }
before do
stub_feature_flags(sse_erb_support: project)
@@ -67,7 +69,7 @@ RSpec.describe Gitlab::StaticSiteEditor::Config do
end
context 'when feature flag is disabled' do
- let(:file_path) { 'FEATURE_OFF.md.erb' }
+ let(:path) { 'FEATURE_OFF.md.erb' }
before do
stub_feature_flags(sse_erb_support: false)
@@ -78,7 +80,7 @@ RSpec.describe Gitlab::StaticSiteEditor::Config do
end
context 'when file path is nested' do
- let(:file_path) { 'lib/README.md' }
+ let(:path) { 'lib/README.md' }
it { is_expected.to include(base_url: '/namespace/project/-/sse/master%2Flib%2FREADME.md') }
end
@@ -90,19 +92,19 @@ RSpec.describe Gitlab::StaticSiteEditor::Config do
end
context 'when file does not have a markdown extension' do
- let(:file_path) { 'README.txt' }
+ let(:path) { 'README.txt' }
it { is_expected.to include(is_supported_content: 'false') }
end
context 'when file does not have an extension' do
- let(:file_path) { 'README' }
+ let(:path) { 'README' }
it { is_expected.to include(is_supported_content: 'false') }
end
context 'when file does not exist' do
- let(:file_path) { 'UNKNOWN.md' }
+ let(:path) { 'UNKNOWN.md' }
it { is_expected.to include(is_supported_content: 'false') }
end
diff --git a/spec/lib/gitlab/submodule_links_spec.rb b/spec/lib/gitlab/submodule_links_spec.rb
index c69326e12be..e2bbda81780 100644
--- a/spec/lib/gitlab/submodule_links_spec.rb
+++ b/spec/lib/gitlab/submodule_links_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe Gitlab::SubmoduleLinks do
end
it 'returns no links' do
- expect(subject).to eq([nil, nil])
+ expect(subject).to be_nil
end
end
@@ -28,17 +28,28 @@ RSpec.describe Gitlab::SubmoduleLinks do
end
it 'returns no links' do
- expect(subject).to eq([nil, nil])
+ expect(subject).to be_nil
end
end
context 'when the submodule is known' do
before do
- stub_urls({ 'gitlab-foss' => 'git@gitlab.com:gitlab-org/gitlab-foss.git' })
+ gitlab_foss = 'git@gitlab.com:gitlab-org/gitlab-foss.git'
+
+ stub_urls({
+ 'ref' => { 'gitlab-foss' => gitlab_foss },
+ 'other_ref' => { 'gitlab-foss' => gitlab_foss },
+ 'signed-commits' => { 'gitlab-foss' => gitlab_foss },
+ 'special_ref' => { 'gitlab-foss' => 'git@OTHER.com:gitlab-org/gitlab-foss.git' }
+ })
end
it 'returns links and caches the by ref' do
- expect(subject).to eq(['https://gitlab.com/gitlab-org/gitlab-foss', 'https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gitlab.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash')
+ expect(subject.compare).to be_nil
+ end
cache_store = links.instance_variable_get("@cache_store")
@@ -49,13 +60,46 @@ RSpec.describe Gitlab::SubmoduleLinks do
let(:ref) { 'signed-commits' }
it 'returns links' do
- expect(subject).to eq(['https://gitlab.com/gitlab-org/gitlab-foss', 'https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash'])
+ aggregate_failures do
+ expect(subject.web).to eq('https://gitlab.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash')
+ expect(subject.compare).to be_nil
+ end
+ end
+ end
+
+ context 'and the diff information is available' do
+ let(:old_ref) { 'other_ref' }
+ let(:diff_file) { double(old_blob: double(id: 'old-hash', path: 'gitlab-foss'), old_content_sha: old_ref) }
+
+ subject { links.for(submodule_item, ref, diff_file) }
+
+ it 'the returned links include the compare link' do
+ aggregate_failures do
+ expect(subject.web).to eq('https://gitlab.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash')
+ expect(subject.compare).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/compare/old-hash...hash')
+ end
+ end
+
+ context 'but the submodule url has changed' do
+ let(:old_ref) { 'special_ref' }
+
+ it 'the returned links do not include the compare link' do
+ aggregate_failures do
+ expect(subject.web).to eq('https://gitlab.com/gitlab-org/gitlab-foss')
+ expect(subject.tree).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/tree/hash')
+ expect(subject.compare).to be_nil
+ end
+ end
end
end
end
end
- def stub_urls(urls)
- allow(repo).to receive(:submodule_urls_for).and_return(urls)
+ def stub_urls(urls_by_ref)
+ allow(repo).to receive(:submodule_urls_for) do |ref|
+ urls_by_ref[ref] if urls_by_ref
+ end
end
end
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 e776284b3e8..e2751d194d3 100644
--- a/spec/lib/gitlab/template/finders/global_template_finder_spec.rb
+++ b/spec/lib/gitlab/template/finders/global_template_finder_spec.rb
@@ -15,9 +15,9 @@ RSpec.describe Gitlab::Template::Finders::GlobalTemplateFinder do
FileUtils.rm_rf(base_dir)
end
- subject(:finder) { described_class.new(base_dir, '', { 'General' => '', 'Bar' => 'Bar' }, exclusions: exclusions) }
+ subject(:finder) { described_class.new(base_dir, '', { 'General' => '', 'Bar' => 'Bar' }, excluded_patterns: excluded_patterns) }
- let(:exclusions) { [] }
+ let(:excluded_patterns) { [] }
describe '.find' do
context 'with a non-prefixed General template' do
@@ -38,7 +38,7 @@ RSpec.describe Gitlab::Template::Finders::GlobalTemplateFinder do
end
context 'while listed as an exclusion' do
- let(:exclusions) { %w[test-template] }
+ let(:excluded_patterns) { [%r{^test-template$}] }
it 'does not find the template without a prefix' do
expect(finder.find('test-template')).to be_nil
@@ -77,7 +77,7 @@ RSpec.describe Gitlab::Template::Finders::GlobalTemplateFinder do
end
context 'while listed as an exclusion' do
- let(:exclusions) { %w[Bar/test-template] }
+ let(:excluded_patterns) { [%r{^Bar/test-template$}] }
it 'does not find the template with a prefix' do
expect(finder.find('Bar/test-template')).to be_nil
@@ -96,6 +96,17 @@ RSpec.describe Gitlab::Template::Finders::GlobalTemplateFinder do
expect(finder.find('Bar/test-template')).to be_nil
end
end
+
+ context 'while listed as an exclusion' do
+ let(:excluded_patterns) { [%r{\.latest$}] }
+
+ it 'excludes the template matched the pattern' do
+ create_template!('test-template.latest')
+
+ expect(finder.find('test-template')).to be_present
+ expect(finder.find('test-template.latest')).to be_nil
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
index 55444114d39..26c83ed6793 100644
--- a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
+++ b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
@@ -13,6 +13,12 @@ RSpec.describe Gitlab::Template::GitlabCiYmlTemplate do
expect(all).to include('Docker')
expect(all).to include('Ruby')
end
+
+ it 'does not include Browser-Performance template in FOSS' do
+ all = subject.all.map(&:name)
+
+ expect(all).not_to include('Browser-Performance') unless Gitlab.ee?
+ end
end
describe '#content' do
diff --git a/spec/lib/gitlab/tracking/incident_management_spec.rb b/spec/lib/gitlab/tracking/incident_management_spec.rb
index e8131b4eeee..9c49c76ead7 100644
--- a/spec/lib/gitlab/tracking/incident_management_spec.rb
+++ b/spec/lib/gitlab/tracking/incident_management_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Gitlab::Tracking::IncidentManagement do
.with(
'IncidentManagement::Settings',
label,
- value || kind_of(Hash)
+ value || any_args
)
end
end
diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb
index 65b6d9c8899..f0bf7b9964f 100644
--- a/spec/lib/gitlab/tracking_spec.rb
+++ b/spec/lib/gitlab/tracking_spec.rb
@@ -9,7 +9,6 @@ RSpec.describe Gitlab::Tracking do
stub_application_setting(snowplow_collector_hostname: 'gitfoo.com')
stub_application_setting(snowplow_cookie_domain: '.gitfoo.com')
stub_application_setting(snowplow_app_id: '_abc123_')
- stub_application_setting(snowplow_iglu_registry_url: 'https://example.org')
end
describe '.snowplow_options' do
@@ -20,8 +19,7 @@ RSpec.describe Gitlab::Tracking do
cookieDomain: '.gitfoo.com',
appId: '_abc123_',
formTracking: true,
- linkClickTracking: true,
- igluRegistryUrl: 'https://example.org'
+ linkClickTracking: true
}
expect(subject.snowplow_options(nil)).to match(expected_fields)
diff --git a/spec/lib/gitlab/updated_notes_paginator_spec.rb b/spec/lib/gitlab/updated_notes_paginator_spec.rb
index eedc11777d4..ce6a7719fb4 100644
--- a/spec/lib/gitlab/updated_notes_paginator_spec.rb
+++ b/spec/lib/gitlab/updated_notes_paginator_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Gitlab::UpdatedNotesPaginator do
let(:page_1_boundary) { page_1.last.updated_at + NotesFinder::FETCH_OVERLAP }
around do |example|
- Timecop.freeze do
+ freeze_time do
example.run
end
end
diff --git a/spec/lib/gitlab/usage_data/topology_spec.rb b/spec/lib/gitlab/usage_data/topology_spec.rb
index 7f4a25297e6..b8462e0290c 100644
--- a/spec/lib/gitlab/usage_data/topology_spec.rb
+++ b/spec/lib/gitlab/usage_data/topology_spec.rb
@@ -6,23 +6,23 @@ RSpec.describe Gitlab::UsageData::Topology do
include UsageDataHelpers
describe '#topology_usage_data' do
- subject { described_class.new.topology_usage_data }
+ subject { topology.topology_usage_data }
+
+ let(:topology) { described_class.new }
+ let(:prometheus_client) { Gitlab::PrometheusClient.new('http://localhost:9090') }
+ let(:fallback) { {} }
before do
# this pins down time shifts when benchmarking durations
allow(Process).to receive(:clock_gettime).and_return(0)
end
- context 'when embedded Prometheus server is enabled' do
- before do
- expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(true)
- expect(Gitlab::Prometheus::Internal).to receive(:uri).and_return('http://prom:9090')
- end
-
+ shared_examples 'query topology data from Prometheus' do
context 'tracking node metrics' do
it 'contains node level metrics for each instance' do
- expect_prometheus_api_to(
+ expect_prometheus_client_to(
receive_app_request_volume_query,
+ receive_query_apdex_ratio_query,
receive_node_memory_query,
receive_node_memory_utilization_query,
receive_node_cpu_count_query,
@@ -38,6 +38,7 @@ RSpec.describe Gitlab::UsageData::Topology do
expect(subject[:topology]).to eq({
duration_s: 0,
application_requests_per_hour: 36,
+ query_apdex_weekly_average: 0.996,
failures: [],
nodes: [
{
@@ -105,8 +106,9 @@ RSpec.describe Gitlab::UsageData::Topology do
context 'and some node memory metrics are missing' do
it 'removes the respective entries and includes the failures' do
- expect_prometheus_api_to(
+ expect_prometheus_client_to(
receive_app_request_volume_query(result: []),
+ receive_query_apdex_ratio_query(result: []),
receive_node_memory_query(result: []),
receive_node_memory_utilization_query(result: []),
receive_node_cpu_count_query,
@@ -123,6 +125,7 @@ RSpec.describe Gitlab::UsageData::Topology do
duration_s: 0,
failures: [
{ 'app_requests' => 'empty_result' },
+ { 'query_apdex' => 'empty_result' },
{ 'node_memory' => 'empty_result' },
{ 'node_memory_utilization' => 'empty_result' },
{ 'service_rss' => 'empty_result' },
@@ -243,8 +246,9 @@ RSpec.describe Gitlab::UsageData::Topology do
end
it 'normalizes equivalent instance values and maps them to the same node' do
- expect_prometheus_api_to(
+ expect_prometheus_client_to(
receive_app_request_volume_query(result: []),
+ receive_query_apdex_ratio_query(result: []),
receive_node_memory_query(result: node_memory_response),
receive_node_memory_utilization_query(result: node_memory_utilization_response),
receive_node_cpu_count_query(result: []),
@@ -261,6 +265,7 @@ RSpec.describe Gitlab::UsageData::Topology do
duration_s: 0,
failures: [
{ 'app_requests' => 'empty_result' },
+ { 'query_apdex' => 'empty_result' },
{ 'node_cpus' => 'empty_result' },
{ 'node_cpu_utilization' => 'empty_result' },
{ 'service_uss' => 'empty_result' },
@@ -307,8 +312,9 @@ RSpec.describe Gitlab::UsageData::Topology do
context 'and node metrics are missing but service metrics exist' do
it 'still reports service metrics' do
- expect_prometheus_api_to(
+ expect_prometheus_client_to(
receive_app_request_volume_query(result: []),
+ receive_query_apdex_ratio_query(result: []),
receive_node_memory_query(result: []),
receive_node_memory_utilization_query(result: []),
receive_node_cpu_count_query(result: []),
@@ -325,6 +331,7 @@ RSpec.describe Gitlab::UsageData::Topology do
duration_s: 0,
failures: [
{ 'app_requests' => 'empty_result' },
+ { 'query_apdex' => 'empty_result' },
{ 'node_memory' => 'empty_result' },
{ 'node_memory_utilization' => 'empty_result' },
{ 'node_cpus' => 'empty_result' },
@@ -380,8 +387,9 @@ RSpec.describe Gitlab::UsageData::Topology do
end
it 'filters out unknown service data and reports the unknown services as a failure' do
- expect_prometheus_api_to(
+ expect_prometheus_client_to(
receive_app_request_volume_query(result: []),
+ receive_query_apdex_ratio_query(result: []),
receive_node_memory_query(result: []),
receive_node_memory_utilization_query(result: []),
receive_node_cpu_count_query(result: []),
@@ -404,24 +412,25 @@ RSpec.describe Gitlab::UsageData::Topology do
context 'and an error is raised when querying Prometheus' do
context 'without timeout failures' do
it 'returns empty result and executes subsequent queries as usual' do
- expect_prometheus_api_to receive(:query)
- .at_least(:once)
- .and_raise(Gitlab::PrometheusClient::ConnectionError)
+ expect_prometheus_client_to(
+ receive(:query).at_least(:once).and_raise(Gitlab::PrometheusClient::UnexpectedResponseError)
+ )
expect(subject[:topology]).to eq({
duration_s: 0,
failures: [
- { 'app_requests' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'node_memory' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'node_memory_utilization' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'node_cpus' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'node_cpu_utilization' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'node_uname_info' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'service_rss' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'service_uss' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'service_pss' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'service_process_count' => 'Gitlab::PrometheusClient::ConnectionError' },
- { 'service_workers' => 'Gitlab::PrometheusClient::ConnectionError' }
+ { 'app_requests' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'query_apdex' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'node_memory' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'node_memory_utilization' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'node_cpus' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'node_cpu_utilization' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'node_uname_info' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'service_rss' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'service_uss' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'service_pss' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'service_process_count' => 'Gitlab::PrometheusClient::UnexpectedResponseError' },
+ { 'service_workers' => 'Gitlab::PrometheusClient::UnexpectedResponseError' }
],
nodes: []
})
@@ -435,13 +444,15 @@ RSpec.describe Gitlab::UsageData::Topology do
with_them do
it 'returns empty result and cancelled subsequent queries' do
- expect_prometheus_api_to receive(:query)
- .and_raise(exception)
+ expect_prometheus_client_to(
+ receive(:query).and_raise(exception)
+ )
expect(subject[:topology]).to eq({
duration_s: 0,
failures: [
{ 'app_requests' => exception.to_s },
+ { 'query_apdex' => 'timeout_cancellation' },
{ 'node_memory' => 'timeout_cancellation' },
{ 'node_memory_utilization' => 'timeout_cancellation' },
{ 'node_cpus' => 'timeout_cancellation' },
@@ -461,10 +472,8 @@ RSpec.describe Gitlab::UsageData::Topology do
end
end
- context 'when embedded Prometheus server is disabled' do
- it 'returns empty result with no failures' do
- expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(false)
-
+ shared_examples 'returns empty result with no failures' do
+ it do
expect(subject[:topology]).to eq({
duration_s: 0,
failures: []
@@ -472,9 +481,25 @@ RSpec.describe Gitlab::UsageData::Topology do
end
end
+ context 'can reach a ready Prometheus client' do
+ before do
+ expect(topology).to receive(:with_prometheus_client).and_yield(prometheus_client)
+ end
+
+ it_behaves_like 'query topology data from Prometheus'
+ end
+
+ context 'can not reach a ready Prometheus client' do
+ before do
+ expect(topology).to receive(:with_prometheus_client).and_return(fallback)
+ end
+
+ it_behaves_like 'returns empty result with no failures'
+ end
+
context 'when top-level function raises error' do
it 'returns empty result with generic failure' do
- allow(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_raise(RuntimeError)
+ expect(topology).to receive(:with_prometheus_client).and_raise(RuntimeError)
expect(subject[:topology]).to eq({
duration_s: 0,
@@ -486,6 +511,14 @@ RSpec.describe Gitlab::UsageData::Topology do
end
end
+ def receive_ready_check_query(result: nil, raise_error: nil)
+ if raise_error.nil?
+ receive(:ready?).and_return(result.nil? ? true : result)
+ else
+ receive(:ready?).and_raise(raise_error)
+ end
+ end
+
def receive_app_request_volume_query(result: nil)
receive(:query)
.with(/gitlab_usage_ping:ops:rate/)
@@ -497,6 +530,17 @@ RSpec.describe Gitlab::UsageData::Topology do
])
end
+ def receive_query_apdex_ratio_query(result: nil)
+ receive(:query)
+ .with(/gitlab_usage_ping:sql_duration_apdex:ratio_rate5m/)
+ .and_return(result || [
+ {
+ 'metric' => {},
+ 'value' => [1000, '0.996']
+ }
+ ])
+ end
+
def receive_node_memory_query(result: nil)
receive(:query)
.with(/node_memory_total_bytes/, an_instance_of(Hash))
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
new file mode 100644
index 00000000000..2a674557b76
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UsageDataCounters::EditorUniqueCounter, :clean_gitlab_redis_shared_state do
+ let(:user1) { build(:user, id: 1) }
+ let(:user2) { build(:user, id: 2) }
+ let(:user3) { build(:user, id: 3) }
+ let(:time) { Time.zone.now }
+
+ shared_examples 'tracks and counts action' do
+ before do
+ stub_application_setting(usage_ping_enabled: true)
+ end
+
+ specify do
+ aggregate_failures do
+ expect(track_action(author: user1)).to be_truthy
+ expect(track_action(author: user1)).to be_truthy
+ expect(track_action(author: user2)).to be_truthy
+ expect(track_action(author: user3, time: time - 3.days)).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)
+ end
+ end
+
+ it 'does not track edit actions if author is not present' do
+ expect(track_action(author: nil)).to be_nil
+ end
+
+ context 'when feature flag track_editor_edit_actions is disabled' do
+ it 'does not track edit actions' do
+ stub_feature_flags(track_editor_edit_actions: false)
+
+ expect(track_action(author: user1)).to be_nil
+ end
+ end
+ end
+
+ context 'for web IDE edit actions' do
+ it_behaves_like 'tracks and counts action' do
+ def track_action(params)
+ described_class.track_web_ide_edit_action(params)
+ end
+
+ def count_unique(params)
+ described_class.count_web_ide_edit_actions(params)
+ end
+ end
+ end
+
+ context 'for SFE edit actions' do
+ it_behaves_like 'tracks and counts action' do
+ def track_action(params)
+ described_class.track_sfe_edit_action(params)
+ end
+
+ def count_unique(params)
+ described_class.count_sfe_edit_actions(params)
+ end
+ end
+ end
+
+ context 'for snippet editor edit actions' do
+ it_behaves_like 'tracks and counts action' do
+ def track_action(params)
+ described_class.track_snippet_editor_edit_action(params)
+ end
+
+ def count_unique(params)
+ described_class.count_snippet_editor_edit_actions(params)
+ end
+ end
+ end
+
+ it 'can return the count of actions per user deduplicated ' do
+ described_class.track_web_ide_edit_action(author: user1)
+ described_class.track_snippet_editor_edit_action(author: user1)
+ described_class.track_sfe_edit_action(author: user1)
+ described_class.track_web_ide_edit_action(author: user2, time: time - 2.days)
+ described_class.track_web_ide_edit_action(author: user3, time: time - 3.days)
+ described_class.track_snippet_editor_edit_action(author: user3, time: time - 3.days)
+ described_class.track_sfe_edit_action(author: user3, time: time - 3.days)
+
+ expect(described_class.count_edit_using_editor(date_from: time, date_to: Date.today)).to eq(1)
+ expect(described_class.count_edit_using_editor(date_from: time - 5.days, date_to: Date.tomorrow)).to eq(3)
+ 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 2ab349a67d9..f881da71251 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
@@ -8,25 +8,6 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
let(:entity3) { '34rfjuuy-ce56-sa35-ds34-dfer567dfrf2' }
let(:entity4) { '8b9a2671-2abf-4bec-a682-22f6a8f7bf31' }
- let(:weekly_event) { 'g_analytics_contribution' }
- let(:daily_event) { 'g_search' }
- let(:different_aggregation) { 'different_aggregation' }
-
- let(:known_events) do
- [
- { name: "g_analytics_contribution", redis_slot: "analytics", category: "analytics", expiry: 84, aggregation: "weekly" },
- { name: "g_analytics_valuestream", redis_slot: "analytics", category: "analytics", expiry: 84, aggregation: "daily" },
- { name: "g_analytics_productivity", redis_slot: "analytics", category: "productivity", expiry: 84, aggregation: "weekly" },
- { name: "g_compliance_dashboard", redis_slot: "compliance", category: "compliance", aggregation: "weekly" },
- { name: "g_search", category: "global", aggregation: "daily" },
- { name: "different_aggregation", category: "global", aggregation: "monthly" }
- ].map(&:with_indifferent_access)
- end
-
- before do
- allow(described_class).to receive(:known_events).and_return(known_events)
- end
-
around do |example|
# We need to freeze to a reference time
# because visits are grouped by the week number in the year
@@ -37,77 +18,239 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
Timecop.freeze(reference_time) { example.run }
end
- describe '.track_event' do
- it "raise error if metrics don't have same aggregation" do
- expect { described_class.track_event(entity1, different_aggregation, Date.current) } .to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownAggregation)
+ describe '.categories' do
+ it 'gets all unique category names' do
+ expect(described_class.categories).to contain_exactly('analytics', 'compliance', 'ide_edit', 'search', 'source_code', 'incident_management', 'issues_edit')
end
+ end
+
+ describe 'known_events' do
+ let(:weekly_event) { 'g_analytics_contribution' }
+ let(:daily_event) { 'g_analytics_search' }
+ let(:analytics_slot_event) { 'g_analytics_contribution' }
+ let(:compliance_slot_event) { 'g_compliance_dashboard' }
+ let(:category_analytics_event) { 'g_analytics_search' }
+ let(:category_productivity_event) { 'g_analytics_productivity' }
+ let(:no_slot) { 'no_slot' }
+ let(:different_aggregation) { 'different_aggregation' }
+ let(:custom_daily_event) { 'g_analytics_custom' }
- it 'raise error if metrics of unknown aggregation' do
- expect { described_class.track_event(entity1, 'unknown', Date.current) } .to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent)
+ let(:global_category) { 'global' }
+ let(:compliance_category) {'compliance' }
+ let(:productivity_category) {'productivity' }
+ let(:analytics_category) { 'analytics' }
+
+ let(:known_events) do
+ [
+ { name: weekly_event, redis_slot: "analytics", category: analytics_category, expiry: 84, aggregation: "weekly" },
+ { name: daily_event, redis_slot: "analytics", category: analytics_category, expiry: 84, 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" }
+ ].map(&:with_indifferent_access)
end
- end
- describe '.unique_events' do
before do
- # events in current week, should not be counted as week is not complete
- described_class.track_event(entity1, weekly_event, Date.current)
- described_class.track_event(entity2, weekly_event, Date.current)
+ allow(described_class).to receive(:known_events).and_return(known_events)
+ end
- # Events last week
- described_class.track_event(entity1, weekly_event, 2.days.ago)
- described_class.track_event(entity1, weekly_event, 2.days.ago)
+ 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
- # Events 2 weeks ago
- described_class.track_event(entity1, weekly_event, 2.weeks.ago)
+ describe '.track_event' do
+ context 'when usage_ping is disabled' do
+ it 'does not track the event' do
+ stub_application_setting(usage_ping_enabled: false)
- # Events 4 weeks ago
- described_class.track_event(entity3, weekly_event, 4.weeks.ago)
- described_class.track_event(entity4, weekly_event, 29.days.ago)
+ described_class.track_event(entity1, weekly_event, Date.current)
- # events in current day should be counted in daily aggregation
- described_class.track_event(entity1, daily_event, Date.current)
- described_class.track_event(entity2, daily_event, Date.current)
+ expect(Gitlab::Redis::HLL).not_to receive(:add)
+ end
+ end
- # Events last week
- described_class.track_event(entity1, daily_event, 2.days.ago)
- described_class.track_event(entity1, daily_event, 2.days.ago)
+ context 'when usage_ping is enabled' do
+ before do
+ stub_application_setting(usage_ping_enabled: true)
+ end
- # Events 2 weeks ago
- described_class.track_event(entity1, daily_event, 14.days.ago)
+ it "raise error if metrics don't have same aggregation" do
+ expect { described_class.track_event(entity1, different_aggregation, Date.current) } .to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownAggregation)
+ end
- # Events 4 weeks ago
- described_class.track_event(entity3, daily_event, 28.days.ago)
- described_class.track_event(entity4, daily_event, 29.days.ago)
- end
+ it 'raise error if metrics of unknown aggregation' do
+ expect { described_class.track_event(entity1, 'unknown', Date.current) } .to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent)
+ end
- it 'raise error if metrics are not in the same slot' do
- expect { described_class.unique_events(event_names: %w(g_analytics_contribution g_compliance_dashboard), start_date: 4.weeks.ago, end_date: Date.current) }.to raise_error('Events should be in same slot')
- end
+ context 'for weekly events' do
+ it 'sets the keys in Redis to expire automatically after the given expiry time' do
+ described_class.track_event(entity1, "g_analytics_contribution")
- it 'raise error if metrics are not in the same category' do
- expect { described_class.unique_events(event_names: %w(g_analytics_contribution g_analytics_productivity), start_date: 4.weeks.ago, end_date: Date.current) }.to raise_error('Events should be in same category')
- end
+ Gitlab::Redis::SharedState.with do |redis|
+ keys = redis.scan_each(match: "g_{analytics}_contribution-*").to_a
+ expect(keys).not_to be_empty
+
+ keys.each do |key|
+ expect(redis.ttl(key)).to be_within(5.seconds).of(12.weeks)
+ end
+ end
+ end
+
+ it 'sets the keys in Redis to expire automatically after 6 weeks by default' do
+ described_class.track_event(entity1, "g_compliance_dashboard")
+
+ Gitlab::Redis::SharedState.with do |redis|
+ keys = redis.scan_each(match: "g_{compliance}_dashboard-*").to_a
+ expect(keys).not_to be_empty
+
+ keys.each do |key|
+ expect(redis.ttl(key)).to be_within(5.seconds).of(6.weeks)
+ end
+ end
+ end
+ end
+
+ context 'for daily events' do
+ it 'sets the keys in Redis to expire after the given expiry time' do
+ described_class.track_event(entity1, "g_analytics_search")
+
+ Gitlab::Redis::SharedState.with do |redis|
+ keys = redis.scan_each(match: "*-g_{analytics}_search").to_a
+ expect(keys).not_to be_empty
+
+ keys.each do |key|
+ expect(redis.ttl(key)).to be_within(5.seconds).of(84.days)
+ end
+ end
+ end
+
+ it 'sets the keys in Redis to expire after 29 days by default' do
+ described_class.track_event(entity1, "no_slot")
- it "raise error if metrics don't have same aggregation" do
- expect { described_class.unique_events(event_names: %w(g_analytics_contribution g_analytics_valuestream), start_date: 4.weeks.ago, end_date: Date.current) }.to raise_error('Events should have same aggregation level')
+ Gitlab::Redis::SharedState.with do |redis|
+ keys = redis.scan_each(match: "*-{no_slot}").to_a
+ expect(keys).not_to be_empty
+
+ keys.each do |key|
+ expect(redis.ttl(key)).to be_within(5.seconds).of(29.days)
+ end
+ end
+ end
+ end
+ end
end
- context 'when data for the last complete week' do
- it { expect(described_class.unique_events(event_names: weekly_event, start_date: 1.week.ago, end_date: Date.current)).to eq(1) }
+ describe '.unique_events' do
+ before do
+ # events in current week, should not be counted as week is not complete
+ described_class.track_event(entity1, weekly_event, Date.current)
+ described_class.track_event(entity2, weekly_event, Date.current)
+
+ # Events last week
+ described_class.track_event(entity1, weekly_event, 2.days.ago)
+ described_class.track_event(entity1, weekly_event, 2.days.ago)
+ described_class.track_event(entity1, no_slot, 2.days.ago)
+
+ # Events 2 weeks ago
+ described_class.track_event(entity1, weekly_event, 2.weeks.ago)
+
+ # Events 4 weeks ago
+ described_class.track_event(entity3, weekly_event, 4.weeks.ago)
+ described_class.track_event(entity4, weekly_event, 29.days.ago)
+
+ # events in current day should be counted in daily aggregation
+ described_class.track_event(entity1, daily_event, Date.current)
+ described_class.track_event(entity2, daily_event, Date.current)
+
+ # Events last week
+ described_class.track_event(entity1, daily_event, 2.days.ago)
+ described_class.track_event(entity1, daily_event, 2.days.ago)
+
+ # Events 2 weeks ago
+ described_class.track_event(entity1, daily_event, 14.days.ago)
+
+ # Events 4 weeks ago
+ described_class.track_event(entity3, daily_event, 28.days.ago)
+ described_class.track_event(entity4, daily_event, 29.days.ago)
+ end
+
+ it 'raise error if metrics are not in the same slot' do
+ expect { described_class.unique_events(event_names: [compliance_slot_event, analytics_slot_event], start_date: 4.weeks.ago, end_date: Date.current) }.to raise_error('Events should be in same slot')
+ end
+
+ it 'raise error if metrics are not in the same category' do
+ expect { described_class.unique_events(event_names: [category_analytics_event, category_productivity_event], start_date: 4.weeks.ago, end_date: Date.current) }.to raise_error('Events should be in same category')
+ end
+
+ it "raise error if metrics don't have same aggregation" do
+ expect { described_class.unique_events(event_names: [daily_event, weekly_event], start_date: 4.weeks.ago, end_date: Date.current) }.to raise_error('Events should have same aggregation level')
+ end
+
+ context 'when data for the last complete week' do
+ it { expect(described_class.unique_events(event_names: weekly_event, start_date: 1.week.ago, end_date: Date.current)).to eq(1) }
+ end
+
+ context 'when data for the last 4 complete weeks' do
+ it { expect(described_class.unique_events(event_names: weekly_event, start_date: 4.weeks.ago, end_date: Date.current)).to eq(2) }
+ end
+
+ context 'when data for the week 4 weeks ago' do
+ it { expect(described_class.unique_events(event_names: weekly_event, start_date: 4.weeks.ago, end_date: 3.weeks.ago)).to eq(1) }
+ end
+
+ context 'when using daily aggregation' do
+ it { expect(described_class.unique_events(event_names: daily_event, start_date: 7.days.ago, end_date: Date.current)).to eq(2) }
+ it { expect(described_class.unique_events(event_names: daily_event, start_date: 28.days.ago, end_date: Date.current)).to eq(3) }
+ it { expect(described_class.unique_events(event_names: daily_event, start_date: 28.days.ago, end_date: 21.days.ago)).to eq(1) }
+ end
+
+ context 'when no slot is set' do
+ it { expect(described_class.unique_events(event_names: no_slot, start_date: 7.days.ago, end_date: Date.current)).to eq(1) }
+ end
end
+ end
- context 'when data for the last 4 complete weeks' do
- it { expect(described_class.unique_events(event_names: weekly_event, start_date: 4.weeks.ago, end_date: Date.current)).to eq(2) }
+ describe 'unique_events_data' do
+ let(:known_events) do
+ [
+ { name: 'event1_slot', redis_slot: "slot", category: 'category1', aggregation: "weekly" },
+ { name: 'event2_slot', redis_slot: "slot", category: 'category1', aggregation: "weekly" },
+ { name: 'event3', category: 'category2', aggregation: "weekly" },
+ { name: 'event4', category: 'category2', aggregation: "weekly" }
+ ].map(&:with_indifferent_access)
end
- context 'when data for the week 4 weeks ago' do
- it { expect(described_class.unique_events(event_names: weekly_event, start_date: 4.weeks.ago, end_date: 3.weeks.ago)).to eq(1) }
+ before do
+ allow(described_class).to receive(:known_events).and_return(known_events)
+ allow(described_class).to receive(:categories).and_return(%w(category1 category2))
+
+ described_class.track_event(entity1, 'event1_slot', 2.days.ago)
+ described_class.track_event(entity2, 'event2_slot', 2.days.ago)
+ described_class.track_event(entity3, 'event2_slot', 2.weeks.ago)
+
+ # events in different slots
+ described_class.track_event(entity2, 'event3', 2.days.ago)
+ described_class.track_event(entity2, 'event4', 2.days.ago)
end
- context 'when using daily aggregation' do
- it { expect(described_class.unique_events(event_names: daily_event, start_date: 7.days.ago, end_date: Date.current)).to eq(2) }
- it { expect(described_class.unique_events(event_names: daily_event, start_date: 28.days.ago, end_date: Date.current)).to eq(3) }
- it { expect(described_class.unique_events(event_names: daily_event, start_date: 28.days.ago, end_date: 21.days.ago)).to eq(1) }
+ it 'returns the number of unique events for all known events' do
+ results = {
+ 'category1' => {
+ 'event1_slot' => 1,
+ 'event2_slot' => 1,
+ 'category1_total_unique_counts_weekly' => 2,
+ 'category1_total_unique_counts_monthly' => 3
+ },
+ 'category2' => {
+ 'event3' => 1,
+ 'event4' => 1
+ }
+ }
+
+ expect(subject.unique_events_data).to eq(results)
end
end
end
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
new file mode 100644
index 00000000000..479fe36bcdd
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_gitlab_redis_shared_state do
+ let(:user1) { build(:user, id: 1) }
+ let(:user2) { build(:user, id: 2) }
+ let(:user3) { build(:user, id: 3) }
+ let(:time) { Time.zone.now }
+
+ shared_examples 'tracks and counts action' do
+ before do
+ stub_application_setting(usage_ping_enabled: true)
+ end
+
+ def count_unique(date_from:, date_to:)
+ Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: action, start_date: date_from, end_date: date_to)
+ end
+
+ specify do
+ aggregate_failures do
+ expect(track_action(author: user1)).to be_truthy
+ expect(track_action(author: user1)).to be_truthy
+ expect(track_action(author: user2)).to be_truthy
+ expect(track_action(author: user3, time: time - 3.days)).to be_truthy
+
+ expect(count_unique(date_from: time, date_to: time)).to eq(2)
+ expect(count_unique(date_from: time - 5.days, date_to: 1.day.since(time))).to eq(3)
+ end
+ end
+
+ it 'does not track edit actions if author is not present' do
+ expect(track_action(author: nil)).to be_nil
+ end
+
+ context 'when feature flag track_issue_activity_actions is disabled' do
+ it 'does not track edit actions' do
+ stub_feature_flags(track_issue_activity_actions: false)
+
+ expect(track_action(author: user1)).to be_nil
+ end
+ end
+ end
+
+ context 'for Issue title edit actions' do
+ it_behaves_like 'tracks and counts action' do
+ let(:action) { described_class::ISSUE_TITLE_CHANGED }
+
+ def track_action(params)
+ described_class.track_issue_title_changed_action(params)
+ end
+ end
+ end
+
+ context 'for Issue description edit actions' do
+ it_behaves_like 'tracks and counts action' do
+ let(:action) { described_class::ISSUE_DESCRIPTION_CHANGED }
+
+ def track_action(params)
+ described_class.track_issue_description_changed_action(params)
+ end
+ end
+ end
+
+ context 'for Issue assignee edit actions' do
+ it_behaves_like 'tracks and counts action' do
+ let(:action) { described_class::ISSUE_ASSIGNEE_CHANGED }
+
+ def track_action(params)
+ described_class.track_issue_assignee_changed_action(params)
+ end
+ end
+ end
+
+ context 'for Issue make confidential actions' do
+ it_behaves_like 'tracks and counts action' do
+ let(:action) { described_class::ISSUE_MADE_CONFIDENTIAL }
+
+ def track_action(params)
+ described_class.track_issue_made_confidential_action(params)
+ end
+ end
+ end
+
+ context 'for Issue make visible actions' do
+ it_behaves_like 'tracks and counts action' do
+ let(:action) { described_class::ISSUE_MADE_VISIBLE }
+
+ def track_action(params)
+ described_class.track_issue_made_visible_action(params)
+ end
+ end
+ end
+
+ it 'can return the count of actions per user deduplicated', :aggregate_failures do
+ described_class.track_issue_title_changed_action(author: user1)
+ described_class.track_issue_description_changed_action(author: user1)
+ described_class.track_issue_assignee_changed_action(author: user1)
+ described_class.track_issue_title_changed_action(author: user2, time: time - 2.days)
+ described_class.track_issue_title_changed_action(author: user3, time: time - 3.days)
+ described_class.track_issue_description_changed_action(author: user3, time: time - 3.days)
+ described_class.track_issue_assignee_changed_action(author: user3, time: time - 3.days)
+
+ 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))
+
+ expect(today_count).to eq(1)
+ expect(week_count).to eq(3)
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_counters/kubernetes_agent_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/kubernetes_agent_counter_spec.rb
new file mode 100644
index 00000000000..8f9a3e0cd9e
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_counters/kubernetes_agent_counter_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UsageDataCounters::KubernetesAgentCounter do
+ it_behaves_like 'a redis usage counter', 'Kubernetes Agent', :gitops_sync
+
+ it_behaves_like 'a redis usage counter with totals', :kubernetes_agent, gitops_sync: 1
+
+ describe '.increment_gitops_sync' do
+ it 'increments the gtops_sync counter by the new increment amount' do
+ described_class.increment_gitops_sync(7)
+ described_class.increment_gitops_sync(2)
+ described_class.increment_gitops_sync(0)
+
+ expect(described_class.totals).to eq(kubernetes_agent_gitops_sync: 9)
+ end
+
+ it 'raises for negative numbers' do
+ expect { described_class.increment_gitops_sync(-1) }.to raise_error(ArgumentError)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_counters/redis_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/redis_counter_spec.rb
index be528b081c5..d4f6110b3df 100644
--- a/spec/lib/gitlab/usage_data_counters/redis_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/redis_counter_spec.rb
@@ -11,23 +11,47 @@ RSpec.describe Gitlab::UsageDataCounters::RedisCounter, :clean_gitlab_redis_shar
stub_application_setting(usage_ping_enabled: setting_value)
end
- context 'when usage_ping is disabled' do
- let(:setting_value) { false }
+ describe '.increment' do
+ context 'when usage_ping is disabled' do
+ let(:setting_value) { false }
+
+ it 'counter is not increased' do
+ expect do
+ subject.increment(redis_key)
+ end.not_to change { subject.total_count(redis_key) }
+ end
+ end
+
+ context 'when usage_ping is enabled' do
+ let(:setting_value) { true }
- it 'counter is not increased' do
- expect do
- subject.increment(redis_key)
- end.not_to change { subject.total_count(redis_key) }
+ it 'counter is increased' do
+ expect do
+ subject.increment(redis_key)
+ end.to change { subject.total_count(redis_key) }.by(1)
+ end
end
end
- context 'when usage_ping is enabled' do
- let(:setting_value) { true }
+ describe '.increment_by' do
+ context 'when usage_ping is disabled' do
+ let(:setting_value) { false }
+
+ it 'counter is not increased' do
+ expect do
+ subject.increment_by(redis_key, 3)
+ end.not_to change { subject.total_count(redis_key) }
+ end
+ end
+
+ context 'when usage_ping is enabled' do
+ let(:setting_value) { true }
- it 'counter is increased' do
- expect do
- subject.increment(redis_key)
- end.to change { subject.total_count(redis_key) }.by(1)
+ it 'counter is increased' do
+ expect do
+ subject.increment_by(redis_key, 3)
+ end.to change { subject.total_count(redis_key) }.by(3)
+ end
end
end
end
diff --git a/spec/lib/gitlab/usage_data_counters/track_unique_actions_spec.rb b/spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb
index bd348666729..8f5f1347ce8 100644
--- a/spec/lib/gitlab/usage_data_counters/track_unique_actions_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::UsageDataCounters::TrackUniqueActions, :clean_gitlab_redis_shared_state do
+RSpec.describe Gitlab::UsageDataCounters::TrackUniqueEvents, :clean_gitlab_redis_shared_state do
subject(:track_unique_events) { described_class }
let(:time) { Time.zone.now }
@@ -12,7 +12,7 @@ RSpec.describe Gitlab::UsageDataCounters::TrackUniqueActions, :clean_gitlab_redi
end
def count_unique(params)
- track_unique_events.count_unique(params)
+ track_unique_events.count_unique_events(params)
end
context 'tracking an event' do
@@ -33,17 +33,14 @@ RSpec.describe Gitlab::UsageDataCounters::TrackUniqueActions, :clean_gitlab_redi
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: :created, event_target: project, author_id: 5, 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: :pushed, event_target: design, author_id: 6)).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(track_event(event_action: :pushed, event_target: wiki, author_id: 6)).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)
@@ -58,17 +55,13 @@ RSpec.describe Gitlab::UsageDataCounters::TrackUniqueActions, :clean_gitlab_redi
context 'when tracking unsuccessfully' do
using RSpec::Parameterized::TableSyntax
- where(:application_setting, :target, :action) do
- true | Project | :invalid_action
- false | Project | :pushed
- true | :invalid_target | :pushed
+ where(:target, :action) do
+ Project | :invalid_action
+ :invalid_target | :pushed
+ Project | :created
end
with_them do
- before do
- stub_application_setting(usage_ping_enabled: application_setting)
- end
-
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)
diff --git a/spec/lib/gitlab/usage_data_queries_spec.rb b/spec/lib/gitlab/usage_data_queries_spec.rb
new file mode 100644
index 00000000000..7fc77593265
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_queries_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UsageDataQueries do
+ before do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
+ end
+
+ describe '.count' do
+ it 'returns the raw SQL' do
+ expect(described_class.count(User)).to start_with('SELECT COUNT("users"."id") FROM "users"')
+ end
+ end
+
+ describe '.distinct_count' do
+ it 'returns the raw SQL' do
+ expect(described_class.distinct_count(Issue, :author_id)).to eq('SELECT COUNT(DISTINCT "issues"."author_id") FROM "issues"')
+ end
+ end
+
+ describe '.redis_usage_data' do
+ subject(:redis_usage_data) { described_class.redis_usage_data { 42 } }
+
+ it 'returns a class for redis_usage_data with a counter call' do
+ expect(described_class.redis_usage_data(Gitlab::UsageDataCounters::WikiPageCounter))
+ .to eq(redis_usage_data_counter: Gitlab::UsageDataCounters::WikiPageCounter)
+ end
+
+ it 'returns a stringified block for redis_usage_data with a block' do
+ is_expected.to include(:redis_usage_data_block)
+ expect(redis_usage_data[:redis_usage_data_block]).to start_with('#<Proc:')
+ end
+ end
+
+ describe '.sum' do
+ it 'returns the raw SQL' do
+ expect(described_class.sum(Issue, :weight)).to eq('SELECT SUM("issues"."weight") FROM "issues"')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 3be8a770b2b..6631a0d3cc6 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -12,19 +12,20 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
describe '.uncached_data' do
describe '.usage_activity_by_stage' do
- it 'includes usage_activity_by_stage data' do
- uncached_data = described_class.uncached_data
+ subject { described_class.uncached_data }
- expect(uncached_data).to include(:usage_activity_by_stage)
- expect(uncached_data).to include(:usage_activity_by_stage_monthly)
- expect(uncached_data[:usage_activity_by_stage])
+ it 'includes usage_activity_by_stage data' do
+ is_expected.to include(:usage_activity_by_stage)
+ is_expected.to include(:usage_activity_by_stage_monthly)
+ expect(subject[:usage_activity_by_stage])
.to include(:configure, :create, :manage, :monitor, :plan, :release, :verify)
- expect(uncached_data[:usage_activity_by_stage_monthly])
+ expect(subject[:usage_activity_by_stage_monthly])
.to include(:configure, :create, :manage, :monitor, :plan, :release, :verify)
end
it 'clears memoized values' do
values = %i(issue_minimum_id issue_maximum_id
+ project_minimum_id project_maximum_id
user_minimum_id user_maximum_id unique_visit_service
deployment_minimum_id deployment_maximum_id
approval_merge_request_rule_minimum_id
@@ -33,15 +34,13 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(described_class).to receive(:clear_memoization).with(key)
end
- described_class.uncached_data
+ subject
end
it 'merge_requests_users is included only in montly counters' do
- uncached_data = described_class.uncached_data
-
- expect(uncached_data[:usage_activity_by_stage][:create])
+ expect(subject[:usage_activity_by_stage][:create])
.not_to include(:merge_requests_users)
- expect(uncached_data[:usage_activity_by_stage_monthly][:create])
+ expect(subject[:usage_activity_by_stage_monthly][:create])
.to include(:merge_requests_users)
end
end
@@ -56,6 +55,21 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
+ describe 'usage_activity_by_stage_package' do
+ it 'includes accurate usage_activity_by_stage data' do
+ for_defined_days_back do
+ create(:project, packages: [create(:package)] )
+ end
+
+ expect(described_class.usage_activity_by_stage_package({})).to eq(
+ projects_with_packages: 2
+ )
+ expect(described_class.usage_activity_by_stage_package(described_class.last_28_days_time_period)).to eq(
+ projects_with_packages: 1
+ )
+ end
+ end
+
describe '.usage_activity_by_stage_configure' do
it 'includes accurate usage_activity_by_stage data' do
for_defined_days_back do
@@ -178,6 +192,58 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
)
end
+ it 'includes imports usage data' do
+ for_defined_days_back do
+ user = create(:user)
+
+ %w(gitlab_project gitlab github bitbucket bitbucket_server gitea git manifest fogbugz phabricator).each do |type|
+ create(:project, import_type: type, creator_id: user.id)
+ end
+
+ jira_project = create(:project, creator_id: user.id)
+ create(:jira_import_state, :finished, project: jira_project)
+ end
+
+ expect(described_class.usage_activity_by_stage_manage({})).to include(
+ {
+ projects_imported: {
+ gitlab_project: 2,
+ gitlab: 2,
+ github: 2,
+ bitbucket: 2,
+ bitbucket_server: 2,
+ gitea: 2,
+ git: 2,
+ manifest: 2
+ },
+ issues_imported: {
+ jira: 2,
+ fogbugz: 2,
+ phabricator: 2
+ }
+ }
+ )
+ expect(described_class.usage_activity_by_stage_manage(described_class.last_28_days_time_period)).to include(
+ {
+ projects_imported: {
+ gitlab_project: 1,
+ gitlab: 1,
+ github: 1,
+ bitbucket: 1,
+ bitbucket_server: 1,
+ gitea: 1,
+ git: 1,
+ manifest: 1
+ },
+ issues_imported: {
+ jira: 1,
+ fogbugz: 1,
+ phabricator: 1
+ }
+ }
+ )
+ end
+
def omniauth_providers
[
OpenStruct.new(name: 'google_oauth2'),
@@ -218,6 +284,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
create(:issue, project: project, author: User.support_bot)
create(:note, project: project, noteable: issue, author: user)
create(:todo, project: project, target: issue, author: user)
+ create(:jira_service, :jira_cloud_service, active: true, project: create(:project, :jira_dvcs_cloud, creator: user))
+ create(:jira_service, active: true, project: create(:project, :jira_dvcs_server, creator: user))
end
expect(described_class.usage_activity_by_stage_plan({})).to include(
@@ -226,7 +294,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
projects: 2,
todos: 2,
service_desk_enabled_projects: 2,
- service_desk_issues: 2
+ service_desk_issues: 2,
+ projects_jira_active: 2,
+ projects_jira_dvcs_cloud_active: 2,
+ projects_jira_dvcs_server_active: 2
)
expect(described_class.usage_activity_by_stage_plan(described_class.last_28_days_time_period)).to include(
issues: 2,
@@ -234,7 +305,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
projects: 1,
todos: 1,
service_desk_enabled_projects: 1,
- service_desk_issues: 1
+ service_desk_issues: 1,
+ projects_jira_active: 1,
+ projects_jira_dvcs_cloud_active: 1,
+ projects_jira_dvcs_server_active: 1
)
end
end
@@ -325,10 +399,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(UsageDataHelpers::COUNTS_KEYS - count_data.keys).to be_empty
end
- it 'gathers usage counts monthly hash' do
- expect(subject[:counts_monthly]).to be_an(Hash)
- end
-
it 'gathers usage counts correctly' do
count_data = subject[:counts]
@@ -392,6 +462,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:clusters_applications_jupyter]).to eq(1)
expect(count_data[:clusters_applications_cilium]).to eq(1)
expect(count_data[:clusters_management_project]).to eq(1)
+ expect(count_data[:kubernetes_agents]).to eq(2)
+ expect(count_data[:kubernetes_agents_with_token]).to eq(1)
expect(count_data[:deployments]).to eq(4)
expect(count_data[:successful_deployments]).to eq(2)
@@ -399,6 +471,9 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:snippets]).to eq(6)
expect(count_data[:personal_snippets]).to eq(2)
expect(count_data[:project_snippets]).to eq(4)
+
+ expect(count_data[:projects_with_packages]).to eq(2)
+ expect(count_data[:packages]).to eq(4)
end
it 'gathers object store usage correctly' do
@@ -411,10 +486,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
)
end
- it 'gathers topology data' do
- expect(subject.keys).to include(:topology)
- end
-
context 'with existing container expiration policies' do
let_it_be(:disabled) { create(:container_expiration_policy, enabled: false) }
let_it_be(:enabled) { create(:container_expiration_policy, enabled: true) }
@@ -491,9 +562,16 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(counts_monthly[:snippets]).to eq(3)
expect(counts_monthly[:personal_snippets]).to eq(1)
expect(counts_monthly[:project_snippets]).to eq(2)
+ expect(counts_monthly[:packages]).to eq(3)
end
end
+ describe '.usage_counters' do
+ subject { described_class.usage_counters }
+
+ it { is_expected.to include(:kubernetes_agent_gitops_sync) }
+ end
+
describe '.usage_data_counters' do
subject { described_class.usage_data_counters }
@@ -592,6 +670,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(subject[:git][:version]).to eq(Gitlab::Git.version)
expect(subject[:database][:adapter]).to eq(Gitlab::Database.adapter_name)
expect(subject[:database][:version]).to eq(Gitlab::Database.version)
+ expect(subject[:mail][:smtp_server]).to eq(ActionMailer::Base.smtp_settings[:address])
expect(subject[:gitaly][:version]).to be_present
expect(subject[:gitaly][:servers]).to be >= 1
expect(subject[:gitaly][:clusters]).to be >= 0
@@ -878,24 +957,25 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
- describe '.merge_requests_users' do
- let(:time_period) { { created_at: 2.days.ago..Time.current } }
- let(:merge_request) { create(:merge_request) }
- let(:other_user) { create(:user) }
- let(:another_user) { create(:user) }
+ 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
- create(:event, target: merge_request, author: merge_request.author, created_at: 1.day.ago)
- create(:event, target: merge_request, author: merge_request.author, created_at: 1.hour.ago)
- create(:event, target: merge_request, author: merge_request.author, created_at: 3.days.ago)
- create(:event, target: merge_request, author: other_user, created_at: 1.day.ago)
- create(:event, target: merge_request, author: other_user, created_at: 1.hour.ago)
- create(:event, target: merge_request, author: other_user, created_at: 3.days.ago)
- create(:event, target: merge_request, author: another_user, created_at: 4.days.ago)
+ 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(2)
+ expect(described_class.merge_requests_users(time_period)).to eq(3)
end
end
@@ -910,9 +990,12 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
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) }
before do
- counter = Gitlab::UsageDataCounters::TrackUniqueActions
+ counter = Gitlab::UsageDataCounters::TrackUniqueEvents
project = Event::TARGET_TYPES[:project]
wiki = Event::TARGET_TYPES[:wiki]
design = Event::TARGET_TYPES[:design]
@@ -922,9 +1005,22 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
counter.track_event(event_action: :pushed, event_target: project, author_id: 2)
counter.track_event(event_action: :pushed, event_target: project, author_id: 3)
counter.track_event(event_action: :pushed, event_target: project, author_id: 4, time: time - 3.days)
- counter.track_event(event_action: :created, event_target: project, author_id: 5, time: time - 3.days)
counter.track_event(event_action: :created, event_target: wiki, author_id: 3)
counter.track_event(event_action: :created, event_target: design, author_id: 3)
+
+ counter = Gitlab::UsageDataCounters::EditorUniqueCounter
+
+ counter.track_web_ide_edit_action(author: user1)
+ counter.track_web_ide_edit_action(author: user1)
+ counter.track_sfe_edit_action(author: user1)
+ counter.track_snippet_editor_edit_action(author: user1)
+ counter.track_snippet_editor_edit_action(author: user1, time: time - 3.days)
+
+ counter.track_web_ide_edit_action(author: user2)
+ counter.track_sfe_edit_action(author: user2)
+
+ counter.track_web_ide_edit_action(author: user3, time: time - 3.days)
+ counter.track_snippet_editor_edit_action(author: user3)
end
it 'returns the distinct count of user actions within the specified time period' do
@@ -932,7 +1028,11 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
{
action_monthly_active_users_design_management: 1,
action_monthly_active_users_project_repo: 3,
- action_monthly_active_users_wiki_repo: 1
+ action_monthly_active_users_wiki_repo: 1,
+ action_monthly_active_users_web_ide_edit: 2,
+ action_monthly_active_users_sfe_edit: 2,
+ action_monthly_active_users_snippet_editor_edit: 2,
+ action_monthly_active_users_ide_edit: 3
}
)
end
@@ -942,8 +1042,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
subject { described_class.analytics_unique_visits_data }
it 'returns the number of unique visits to pages with analytics features' do
- ::Gitlab::Analytics::UniqueVisits.analytics_ids.each do |target_id|
- expect_any_instance_of(::Gitlab::Analytics::UniqueVisits).to receive(:unique_visits_for).with(targets: target_id).and_return(123)
+ ::Gitlab::Analytics::UniqueVisits.analytics_events.each do |target|
+ expect_any_instance_of(::Gitlab::Analytics::UniqueVisits).to receive(:unique_visits_for).with(targets: target).and_return(123)
end
expect_any_instance_of(::Gitlab::Analytics::UniqueVisits).to receive(:unique_visits_for).with(targets: :analytics).and_return(543)
@@ -964,6 +1064,9 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
'p_analytics_repo' => 123,
'i_analytics_cohorts' => 123,
'i_analytics_dev_ops_score' => 123,
+ 'i_analytics_instance_statistics' => 123,
+ 'p_analytics_merge_request' => 123,
+ 'g_analytics_merge_request' => 123,
'analytics_unique_visits_for_any_target' => 543,
'analytics_unique_visits_for_any_target_monthly' => 987
}
@@ -978,8 +1081,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
described_class.clear_memoization(:unique_visit_service)
allow_next_instance_of(::Gitlab::Analytics::UniqueVisits) do |instance|
- ::Gitlab::Analytics::UniqueVisits.compliance_ids.each do |target_id|
- allow(instance).to receive(:unique_visits_for).with(targets: target_id).and_return(123)
+ ::Gitlab::Analytics::UniqueVisits.compliance_events.each do |target|
+ allow(instance).to receive(:unique_visits_for).with(targets: target).and_return(123)
end
allow(instance).to receive(:unique_visits_for).with(targets: :compliance).and_return(543)
@@ -995,6 +1098,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
'g_compliance_audit_events' => 123,
'i_compliance_credential_inventory' => 123,
'i_compliance_audit_events' => 123,
+ 'a_compliance_audit_events_api' => 123,
'compliance_unique_visits_for_any_target' => 543,
'compliance_unique_visits_for_any_target_monthly' => 987
}
@@ -1002,6 +1106,55 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
+ describe '.search_unique_visits_data' do
+ subject { described_class.search_unique_visits_data }
+
+ before do
+ described_class.clear_memoization(:unique_visit_service)
+ events = ::Gitlab::UsageDataCounters::HLLRedisCounter.events_for_category('search')
+ events.each do |event|
+ allow(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:unique_events).with(event_names: event, start_date: 7.days.ago.to_date, end_date: Date.current).and_return(123)
+ end
+ allow(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:unique_events).with(event_names: events, start_date: 7.days.ago.to_date, end_date: Date.current).and_return(543)
+ allow(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:unique_events).with(event_names: events, start_date: 4.weeks.ago.to_date, end_date: Date.current).and_return(987)
+ end
+
+ it 'returns the number of unique visits to pages with search features' do
+ expect(subject).to eq({
+ search_unique_visits: {
+ 'i_search_total' => 123,
+ 'i_search_advanced' => 123,
+ 'i_search_paid' => 123,
+ 'search_unique_visits_for_any_target_weekly' => 543,
+ 'search_unique_visits_for_any_target_monthly' => 987
+ }
+ })
+ end
+ end
+
+ describe 'redis_hll_counters' do
+ subject { described_class.redis_hll_counters }
+
+ let(:categories) { ::Gitlab::UsageDataCounters::HLLRedisCounter.categories }
+ let(:ineligible_total_categories) { ['source_code'] }
+
+ it 'has all know_events' do
+ expect(subject).to have_key(:redis_hll_counters)
+
+ expect(subject[:redis_hll_counters].keys).to match_array(categories)
+
+ categories.each do |category|
+ keys = ::Gitlab::UsageDataCounters::HLLRedisCounter.events_for_category(category)
+
+ if ineligible_total_categories.exclude?(category)
+ keys.append("#{category}_total_unique_counts_weekly", "#{category}_total_unique_counts_monthly")
+ end
+
+ expect(subject[:redis_hll_counters][category].keys).to match_array(keys)
+ end
+ end
+ end
+
describe '.service_desk_counts' do
subject { described_class.send(:service_desk_counts) }
@@ -1014,4 +1167,46 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
service_desk_issues: 2)
end
end
+
+ describe '.snowplow_event_counts' do
+ context 'when self-monitoring project exists' do
+ let_it_be(:project) { create(:project) }
+
+ before do
+ stub_application_setting(self_monitoring_project: project)
+ end
+
+ context 'and product_analytics FF is enabled for it' do
+ before do
+ stub_feature_flags(product_analytics: project)
+
+ create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote')
+ create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote', collector_tstamp: 28.days.ago)
+ end
+
+ it 'returns promoted_issues for the time period' do
+ expect(described_class.snowplow_event_counts[:promoted_issues]).to eq(2)
+ expect(described_class.snowplow_event_counts(
+ time_period: described_class.last_28_days_time_period(column: :collector_tstamp)
+ )[:promoted_issues]).to eq(1)
+ end
+ end
+
+ context 'and product_analytics FF is disabled' do
+ before do
+ stub_feature_flags(product_analytics: false)
+ end
+
+ it 'returns an empty hash' do
+ expect(described_class.snowplow_event_counts).to eq({})
+ end
+ end
+ end
+
+ context 'when self-monitoring project does not exist' do
+ it 'returns an empty hash' do
+ expect(described_class.snowplow_event_counts).to eq({})
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/utils/gzip_spec.rb b/spec/lib/gitlab/utils/gzip_spec.rb
new file mode 100644
index 00000000000..5d1c62e03d3
--- /dev/null
+++ b/spec/lib/gitlab/utils/gzip_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Utils::Gzip do
+ before do
+ example_class = Class.new do
+ include Gitlab::Utils::Gzip
+
+ def lorem_ipsum
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod "\
+ "tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim "\
+ "veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea "\
+ "commodo consequat. Duis aute irure dolor in reprehenderit in voluptate "\
+ "velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat "\
+ "cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id "\
+ "est laborum."
+ end
+ end
+
+ stub_const('ExampleClass', example_class)
+ end
+
+ subject { ExampleClass.new }
+
+ let(:sample_string) { subject.lorem_ipsum }
+ let(:compressed_string) { subject.gzip_compress(sample_string) }
+
+ describe "#gzip_compress" do
+ it "compresses data passed to it" do
+ expect(compressed_string.length).to be < sample_string.length
+ end
+
+ it "returns uncompressed data when encountering Zlib::GzipFile::Error" do
+ expect(ActiveSupport::Gzip).to receive(:compress).and_raise(Zlib::GzipFile::Error)
+
+ expect(compressed_string.length).to eq sample_string.length
+ end
+ end
+
+ describe "#gzip_decompress" do
+ let(:decompressed_string) { subject.gzip_decompress(compressed_string) }
+
+ it "decompresses encoded data" do
+ expect(decompressed_string).to eq sample_string
+ end
+
+ it "returns compressed data when encountering Zlib::GzipFile::Error" do
+ expect(ActiveSupport::Gzip).to receive(:decompress).and_raise(Zlib::GzipFile::Error)
+
+ expect(decompressed_string).not_to eq sample_string.length
+ end
+
+ it "returns unmodified data when it is determined to be uncompressed" do
+ expect(subject.gzip_decompress(sample_string)).to eq sample_string
+ end
+ end
+end
diff --git a/spec/lib/gitlab/utils/markdown_spec.rb b/spec/lib/gitlab/utils/markdown_spec.rb
index 001ff5bc487..93d91f7ed90 100644
--- a/spec/lib/gitlab/utils/markdown_spec.rb
+++ b/spec/lib/gitlab/utils/markdown_spec.rb
@@ -52,6 +52,38 @@ RSpec.describe Gitlab::Utils::Markdown do
end
end
+ context 'when string has a product suffix' do
+ let(:string) { 'My Header (ULTIMATE)' }
+
+ it 'ignores a product suffix' do
+ is_expected.to eq 'my-header'
+ end
+
+ context 'with only modifier' do
+ let(:string) { 'My Header (STARTER ONLY)' }
+
+ it 'ignores a product suffix' do
+ is_expected.to eq 'my-header'
+ end
+ end
+
+ context 'with "*" around a product suffix' do
+ let(:string) { 'My Header **(STARTER)**' }
+
+ it 'ignores a product suffix' do
+ is_expected.to eq 'my-header'
+ end
+ end
+
+ context 'with "*" around a product suffix and only modifier' do
+ let(:string) { 'My Header **(STARTER ONLY)**' }
+
+ it 'ignores a product suffix' do
+ is_expected.to eq 'my-header'
+ end
+ end
+ end
+
context 'when string is empty' do
let(:string) { '' }
diff --git a/spec/lib/gitlab/utils/usage_data_spec.rb b/spec/lib/gitlab/utils/usage_data_spec.rb
index 4675cbd7fa1..362cbaa78e9 100644
--- a/spec/lib/gitlab/utils/usage_data_spec.rb
+++ b/spec/lib/gitlab/utils/usage_data_spec.rb
@@ -37,6 +37,28 @@ RSpec.describe Gitlab::Utils::UsageData do
end
end
+ describe '#sum' do
+ let(:relation) { double(:relation) }
+
+ it 'returns the count when counting succeeds' do
+ allow(Gitlab::Database::BatchCount)
+ .to receive(:batch_sum)
+ .with(relation, :column, batch_size: 100, start: 2, finish: 3)
+ .and_return(1)
+
+ expect(described_class.sum(relation, :column, batch_size: 100, start: 2, finish: 3)).to eq(1)
+ end
+
+ it 'returns the fallback value when counting fails' do
+ stub_const("Gitlab::Utils::UsageData::FALLBACK", 15)
+ allow(Gitlab::Database::BatchCount)
+ .to receive(:batch_sum)
+ .and_raise(ActiveRecord::StatementInvalid.new(''))
+
+ expect(described_class.sum(relation, :column)).to eq(15)
+ end
+ end
+
describe '#alt_usage_data' do
it 'returns the fallback when it gets an error' do
expect(described_class.alt_usage_data { raise StandardError } ).to eq(-1)
@@ -76,26 +98,19 @@ RSpec.describe Gitlab::Utils::UsageData do
end
describe '#with_prometheus_client' do
- context 'when Prometheus is enabled' do
+ shared_examples 'query data from Prometheus' do
it 'yields a client instance and returns the block result' do
- expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(true)
- expect(Gitlab::Prometheus::Internal).to receive(:uri).and_return('http://prom:9090')
-
result = described_class.with_prometheus_client { |client| client }
expect(result).to be_an_instance_of(Gitlab::PrometheusClient)
end
end
- context 'when Prometheus is disabled' do
- before do
- expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(false)
- end
-
+ shared_examples 'does not query data from Prometheus' do
it 'returns nil by default' do
result = described_class.with_prometheus_client { |client| client }
- expect(result).to be nil
+ expect(result).to be_nil
end
it 'returns fallback if provided' do
@@ -104,6 +119,74 @@ RSpec.describe Gitlab::Utils::UsageData do
expect(result).to eq([])
end
end
+
+ shared_examples 'try to query Prometheus with given address' do
+ context 'Prometheus is ready' do
+ before do
+ stub_request(:get, /\/-\/ready/)
+ .to_return(status: 200, body: 'Prometheus is Ready.\n')
+ end
+
+ context 'Prometheus is reachable through HTTPS' do
+ it_behaves_like 'query data from Prometheus'
+ end
+
+ context 'Prometheus is not reachable through HTTPS' do
+ before do
+ stub_request(:get, /https:\/\/.*/).to_raise(Errno::ECONNREFUSED)
+ end
+
+ context 'Prometheus is reachable through HTTP' do
+ it_behaves_like 'query data from Prometheus'
+ end
+
+ context 'Prometheus is not reachable through HTTP' do
+ before do
+ stub_request(:get, /http:\/\/.*/).to_raise(Errno::ECONNREFUSED)
+ end
+
+ it_behaves_like 'does not query data from Prometheus'
+ end
+ end
+ end
+
+ context 'Prometheus is not ready' do
+ before do
+ stub_request(:get, /\/-\/ready/)
+ .to_return(status: 503, body: 'Service Unavailable')
+ end
+
+ it_behaves_like 'does not query data from Prometheus'
+ end
+ end
+
+ context 'when Prometheus server address is available from settings' do
+ before do
+ expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(true)
+ expect(Gitlab::Prometheus::Internal).to receive(:server_address).and_return('prom:9090')
+ end
+
+ it_behaves_like 'try to query Prometheus with given address'
+ end
+
+ context 'when Prometheus server address is available from Consul service discovery' do
+ before do
+ expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(false)
+ expect(Gitlab::Consul::Internal).to receive(:api_url).and_return('http://localhost:8500')
+ expect(Gitlab::Consul::Internal).to receive(:discover_prometheus_server_address).and_return('prom:9090')
+ end
+
+ it_behaves_like 'try to query Prometheus with given address'
+ end
+
+ context 'when Prometheus server address is not available' do
+ before do
+ expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(false)
+ expect(Gitlab::Consul::Internal).to receive(:api_url).and_return(nil)
+ end
+
+ it_behaves_like 'does not query data from Prometheus'
+ end
end
describe '#measure_duration' do
@@ -126,4 +209,50 @@ RSpec.describe Gitlab::Utils::UsageData do
end
end
end
+
+ describe '#track_usage_event' do
+ let(:value) { '9f302fea-f828-4ca9-aef4-e10bd723c0b3' }
+ let(:event_name) { 'my_event' }
+ let(:unknown_event) { 'unknown' }
+ let(:feature) { "usage_data_#{event_name}" }
+
+ context 'with feature enabled' do
+ before do
+ stub_feature_flags(feature => true)
+ end
+
+ it 'tracks redis hll event' do
+ stub_application_setting(usage_ping_enabled: true)
+
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(value, event_name)
+
+ described_class.track_usage_event(event_name, value)
+ end
+
+ it 'does not track event when usage ping is not enabled' do
+ stub_application_setting(usage_ping_enabled: false)
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ described_class.track_usage_event(event_name, value)
+ end
+
+ it 'raise an error for unknown event' do
+ stub_application_setting(usage_ping_enabled: true)
+
+ expect { described_class.track_usage_event(unknown_event, value) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent)
+ end
+ end
+
+ context 'with feature disabled' do
+ before do
+ stub_feature_flags(feature => false)
+ end
+
+ it 'does not track event' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ described_class.track_usage_event(event_name, value)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/web_ide/config/entry/global_spec.rb b/spec/lib/gitlab/web_ide/config/entry/global_spec.rb
index 3a50667163b..3e29bf89785 100644
--- a/spec/lib/gitlab/web_ide/config/entry/global_spec.rb
+++ b/spec/lib/gitlab/web_ide/config/entry/global_spec.rb
@@ -12,8 +12,7 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Global do
context 'when filtering all the entry/node names' do
it 'contains the expected node names' do
- expect(described_class.nodes.keys)
- .to match_array(%i[terminal])
+ expect(described_class.nodes.keys).to match_array(described_class.allowed_keys)
end
end
end
@@ -34,7 +33,7 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Global do
end
it 'creates node object for each entry' do
- expect(global.descendants.count).to eq 1
+ expect(global.descendants.count).to eq described_class.allowed_keys.length
end
it 'creates node object using valid class' do
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index da327ce7706..e9733851590 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe Gitlab::Workhorse do
context "when the repository doesn't have an archive file path" do
before do
- allow(project.repository).to receive(:archive_metadata).and_return(Hash.new)
+ allow(project.repository).to receive(:archive_metadata).and_return({})
end
it "raises an error" do
@@ -424,8 +424,9 @@ RSpec.describe Gitlab::Workhorse do
describe '.send_scaled_image' do
let(:location) { 'http://example.com/avatar.png' }
let(:width) { '150' }
+ let(:content_type) { 'image/png' }
- subject { described_class.send_scaled_image(location, width) }
+ subject { described_class.send_scaled_image(location, width, content_type) }
it 'sets the header correctly' do
key, command, params = decode_workhorse_header(subject)
@@ -434,7 +435,8 @@ RSpec.describe Gitlab::Workhorse do
expect(command).to eq("send-scaled-img")
expect(params).to eq({
'Location' => location,
- 'Width' => width
+ 'Width' => width,
+ 'ContentType' => content_type
}.deep_stringify_keys)
end
end
diff --git a/spec/lib/gitlab_danger_spec.rb b/spec/lib/gitlab_danger_spec.rb
index 49c7a46f321..b534823a888 100644
--- a/spec/lib/gitlab_danger_spec.rb
+++ b/spec/lib/gitlab_danger_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe GitlabDanger do
describe '.local_warning_message' do
it 'returns an informational message with rules that can run' do
- expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changes_size, documentation, frozen_string, duplicate_yarn_dependencies, prettier, eslint, karma, database, commit_messages, telemetry, utility_css')
+ expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changes_size, documentation, frozen_string, duplicate_yarn_dependencies, prettier, eslint, karma, database, commit_messages, telemetry, utility_css, pajamas')
end
end
diff --git a/spec/lib/object_storage/config_spec.rb b/spec/lib/object_storage/config_spec.rb
index a48b5100065..0ead2a1d269 100644
--- a/spec/lib/object_storage/config_spec.rb
+++ b/spec/lib/object_storage/config_spec.rb
@@ -2,6 +2,7 @@
require 'fast_spec_helper'
require 'rspec-parameterized'
+require 'fog/core'
RSpec.describe ObjectStorage::Config do
using RSpec::Parameterized::TableSyntax
@@ -35,6 +36,46 @@ RSpec.describe ObjectStorage::Config do
subject { described_class.new(raw_config.as_json) }
+ describe '#load_provider' do
+ before do
+ subject.load_provider
+ end
+
+ context 'with AWS' do
+ it 'registers AWS as a provider' do
+ expect(Fog.providers.keys).to include(:aws)
+ end
+ end
+
+ context 'with Google' do
+ let(:credentials) do
+ {
+ provider: 'Google',
+ google_storage_access_key_id: 'GOOGLE_ACCESS_KEY_ID',
+ google_storage_secret_access_key: 'GOOGLE_SECRET_ACCESS_KEY'
+ }
+ end
+
+ it 'registers Google as a provider' do
+ expect(Fog.providers.keys).to include(:google)
+ end
+ end
+
+ context 'with Azure' do
+ let(:credentials) do
+ {
+ provider: 'AzureRM',
+ azure_storage_account_name: 'azuretest',
+ azure_storage_access_key: 'ABCD1234'
+ }
+ end
+
+ it 'registers AzureRM as a provider' do
+ expect(Fog.providers.keys).to include(:azurerm)
+ end
+ end
+ end
+
describe '#credentials' do
it { expect(subject.credentials).to eq(credentials) }
end
diff --git a/spec/lib/object_storage/direct_upload_spec.rb b/spec/lib/object_storage/direct_upload_spec.rb
index b11926aeb49..932d579c3cc 100644
--- a/spec/lib/object_storage/direct_upload_spec.rb
+++ b/spec/lib/object_storage/direct_upload_spec.rb
@@ -211,7 +211,7 @@ RSpec.describe ObjectStorage::DirectUpload do
expect(subject[:UseWorkhorseClient]).to be true
expect(subject[:RemoteTempObjectID]).to eq(object_name)
expect(subject[:ObjectStorage][:Provider]).to eq('AzureRM')
- expect(subject[:ObjectStorage][:GoCloudConfig]).to eq({ URL: "azblob://#{bucket_name}" })
+ expect(subject[:ObjectStorage][:GoCloudConfig]).to eq({ URL: gocloud_url })
end
end
@@ -395,20 +395,24 @@ RSpec.describe ObjectStorage::DirectUpload do
}
end
+ let(:has_length) { false }
+ let(:storage_domain) { nil }
let(:storage_url) { 'https://azuretest.blob.core.windows.net' }
+ let(:gocloud_url) { "azblob://#{bucket_name}" }
- context 'when length is known' do
- let(:has_length) { true }
+ it_behaves_like 'a valid AzureRM upload'
+ it_behaves_like 'a valid upload without multipart data'
- it_behaves_like 'a valid AzureRM upload'
- it_behaves_like 'a valid upload without multipart data'
- end
+ context 'when a custom storage domain is used' do
+ let(:storage_domain) { 'blob.core.chinacloudapi.cn' }
+ let(:storage_url) { "https://azuretest.#{storage_domain}" }
+ let(:gocloud_url) { "azblob://#{bucket_name}?domain=#{storage_domain}" }
- context 'when length is unknown' do
- let(:has_length) { false }
+ before do
+ credentials[:azure_storage_domain] = storage_domain
+ end
it_behaves_like 'a valid AzureRM upload'
- it_behaves_like 'a valid upload without multipart data'
end
end
end
diff --git a/spec/lib/product_analytics/tracker_spec.rb b/spec/lib/product_analytics/tracker_spec.rb
index d5e85e6e1cd..0d0660235f1 100644
--- a/spec/lib/product_analytics/tracker_spec.rb
+++ b/spec/lib/product_analytics/tracker_spec.rb
@@ -1,8 +1,57 @@
# frozen_string_literal: true
-require "spec_helper"
+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') }
+
+ describe '.event' do
+ after do
+ described_class.clear_memoization(:snowplow)
+ end
+
+ context 'when usage ping is enabled' do
+ let(:tracker) { double }
+ let(:project_id) { 1 }
+
+ before do
+ stub_application_setting(usage_ping_enabled: true, self_monitoring_project_id: project_id)
+ end
+
+ it 'sends an event to Product Analytics snowplow collector' do
+ expect(SnowplowTracker::AsyncEmitter)
+ .to receive(:new)
+ .with(described_class::COLLECTOR_URL, { protocol: 'http' })
+ .and_return('_emitter_')
+
+ expect(SnowplowTracker::Tracker)
+ .to receive(:new)
+ .with('_emitter_', an_instance_of(SnowplowTracker::Subject), 'gl', project_id.to_s)
+ .and_return(tracker)
+
+ freeze_time do
+ expect(tracker)
+ .to receive(:track_struct_event)
+ .with('category', 'action', '_label_', '_property_', '_value_', nil, (Time.current.to_f * 1000).to_i)
+
+ described_class.event('category', 'action', label: '_label_', property: '_property_',
+ value: '_value_', context: nil)
+ end
+ end
+ end
+
+ context 'when usage ping is disabled' do
+ before do
+ stub_application_setting(usage_ping_enabled: false)
+ end
+
+ it 'does not send an event' do
+ expect(SnowplowTracker::Tracker).not_to receive(:new)
+
+ described_class.event('category', 'action', label: '_label_', property: '_property_',
+ value: '_value_', context: nil)
+ end
+ end
+ end
end
diff --git a/spec/lib/uploaded_file_spec.rb b/spec/lib/uploaded_file_spec.rb
index 5ff46193b4f..8425e1dbd46 100644
--- a/spec/lib/uploaded_file_spec.rb
+++ b/spec/lib/uploaded_file_spec.rb
@@ -14,226 +14,343 @@ RSpec.describe UploadedFile do
FileUtils.rm_f(temp_file)
end
- describe ".from_params" do
- let(:upload_path) { nil }
- let(:file_path_override) { nil }
+ context 'from_params functions' do
+ RSpec.shared_examples 'using the file path' do |filename:, content_type:, sha256:, path_suffix:|
+ it { is_expected.not_to be_nil }
+
+ it 'sets properly the attributes' do
+ expect(subject.original_filename).to eq(filename)
+ expect(subject.content_type).to eq(content_type)
+ expect(subject.sha256).to eq(sha256)
+ expect(subject.remote_id).to be_nil
+ expect(subject.path).to end_with(path_suffix)
+ end
+
+ it 'handles a blank path' do
+ params['file.path'] = ''
+
+ # Not a real file, so can't determine size itself
+ params['file.size'] = 1.byte
- after do
- FileUtils.rm_r(upload_path) if upload_path
+ expect { described_class.from_params(params, :file, upload_path) }
+ .not_to raise_error
+ end
end
- subject do
- described_class.from_params(params, :file, upload_path, file_path_override)
+ RSpec.shared_examples 'using the remote id' do |filename:, content_type:, sha256:, size:, remote_id:|
+ it { is_expected.not_to be_nil }
+
+ it 'sets properly the attributes' do
+ expect(subject.original_filename).to eq(filename)
+ expect(subject.content_type).to eq('application/octet-stream')
+ expect(subject.sha256).to eq('sha256')
+ expect(subject.path).to be_nil
+ expect(subject.size).to eq(123456)
+ expect(subject.remote_id).to eq('1234567890')
+ end
end
- context 'when valid file is specified' do
- context 'only local path is specified' do
- let(:params) do
- { 'file.path' => temp_file.path }
- end
+ describe '.from_params_without_field' do
+ let(:upload_path) { nil }
- it { is_expected.not_to be_nil }
+ after do
+ FileUtils.rm_r(upload_path) if upload_path
+ end
- it "generates filename from path" do
- expect(subject.original_filename).to eq(::File.basename(temp_file.path))
- end
+ subject do
+ described_class.from_params_without_field(params, [upload_path, Dir.tmpdir])
end
- context 'all parameters are specified' do
- RSpec.shared_context 'filepath override' do
- let(:temp_file_override) { Tempfile.new(%w[override override], temp_dir) }
- let(:file_path_override) { temp_file_override.path }
+ context 'when valid file is specified' do
+ context 'only local path is specified' do
+ let(:params) { { 'path' => temp_file.path } }
- before do
- FileUtils.touch(temp_file_override)
- end
+ it { is_expected.not_to be_nil }
- after do
- FileUtils.rm_f(temp_file_override)
+ it 'generates filename from path' do
+ expect(subject.original_filename).to eq(::File.basename(temp_file.path))
end
end
- RSpec.shared_examples 'using the file path' do |filename:, content_type:, sha256:, path_suffix:|
- it 'sets properly the attributes' do
- expect(subject.original_filename).to eq(filename)
- expect(subject.content_type).to eq(content_type)
- expect(subject.sha256).to eq(sha256)
- expect(subject.remote_id).to be_nil
- expect(subject.path).to end_with(path_suffix)
+ context 'all parameters are specified' do
+ context 'with a filepath' do
+ let(:params) do
+ { 'path' => temp_file.path,
+ 'name' => 'dir/my file&.txt',
+ 'type' => 'my/type',
+ 'sha256' => 'sha256' }
+ end
+
+ it_behaves_like 'using the file path',
+ filename: 'my_file_.txt',
+ content_type: 'my/type',
+ sha256: 'sha256',
+ path_suffix: 'test'
end
- it 'handles a blank path' do
- params['file.path'] = ''
-
- # Not a real file, so can't determine size itself
- params['file.size'] = 1.byte
+ context 'with a remote id' do
+ let(:params) do
+ {
+ 'name' => 'dir/my file&.txt',
+ 'sha256' => 'sha256',
+ 'remote_url' => 'http://localhost/file',
+ 'remote_id' => '1234567890',
+ 'etag' => 'etag1234567890',
+ 'size' => '123456'
+ }
+ end
+
+ it_behaves_like 'using the remote id',
+ filename: 'my_file_.txt',
+ content_type: 'application/octet-stream',
+ sha256: 'sha256',
+ size: 123456,
+ remote_id: '1234567890'
+ end
- expect { described_class.from_params(params, :file, upload_path) }
- .not_to raise_error
+ context 'with a path and a remote id' do
+ let(:params) do
+ {
+ 'path' => temp_file.path,
+ 'name' => 'dir/my file&.txt',
+ 'sha256' => 'sha256',
+ 'remote_url' => 'http://localhost/file',
+ 'remote_id' => '1234567890',
+ 'etag' => 'etag1234567890',
+ 'size' => '123456'
+ }
+ end
+
+ it_behaves_like 'using the remote id',
+ filename: 'my_file_.txt',
+ content_type: 'application/octet-stream',
+ sha256: 'sha256',
+ size: 123456,
+ remote_id: '1234567890'
end
end
+ end
- RSpec.shared_examples 'using the remote id' do |filename:, content_type:, sha256:, size:, remote_id:|
- it 'sets properly the attributes' do
- expect(subject.original_filename).to eq(filename)
- expect(subject.content_type).to eq('application/octet-stream')
- expect(subject.sha256).to eq('sha256')
- expect(subject.path).to be_nil
- expect(subject.size).to eq(123456)
- expect(subject.remote_id).to eq('1234567890')
- end
+ context 'when no params are specified' do
+ let(:params) { {} }
+
+ it 'does not return an object' do
+ is_expected.to be_nil
end
+ end
- context 'with a filepath' do
- let(:params) do
- { 'file.path' => temp_file.path,
- 'file.name' => 'dir/my file&.txt',
- 'file.type' => 'my/type',
- 'file.sha256' => 'sha256' }
- end
+ context 'when verifying allowed paths' do
+ let(:params) { { 'path' => temp_file.path } }
+
+ context 'when file is stored in system temporary folder' do
+ let(:temp_dir) { Dir.tmpdir }
it { is_expected.not_to be_nil }
+ end
+
+ context 'when file is stored in user provided upload path' do
+ let(:upload_path) { Dir.mktmpdir }
+ let(:temp_dir) { upload_path }
- it_behaves_like 'using the file path',
- filename: 'my_file_.txt',
- content_type: 'my/type',
- sha256: 'sha256',
- path_suffix: 'test'
+ it { is_expected.not_to be_nil }
end
- context 'with a filepath override' do
- include_context 'filepath override'
+ context 'when file is stored outside of user provided upload path' do
+ let!(:generated_dir) { Dir.mktmpdir }
+ let!(:temp_dir) { Dir.mktmpdir }
- let(:params) do
- { 'file.path' => temp_file.path,
- 'file.name' => 'dir/my file&.txt',
- 'file.type' => 'my/type',
- 'file.sha256' => 'sha256' }
+ before do
+ # We overwrite default temporary path
+ allow(Dir).to receive(:tmpdir).and_return(generated_dir)
end
- it { is_expected.not_to be_nil }
-
- it_behaves_like 'using the file path',
- filename: 'my_file_.txt',
- content_type: 'my/type',
- sha256: 'sha256',
- path_suffix: 'override'
+ it 'raises an error' do
+ expect { subject }.to raise_error(UploadedFile::InvalidPathError, /insecure path used/)
+ end
end
+ end
+ end
- context 'with a remote id' do
- let(:params) do
- {
- 'file.name' => 'dir/my file&.txt',
- 'file.sha256' => 'sha256',
- 'file.remote_url' => 'http://localhost/file',
- 'file.remote_id' => '1234567890',
- 'file.etag' => 'etag1234567890',
- 'file.size' => '123456'
- }
- end
+ describe '.from_params' do
+ let(:upload_path) { nil }
+ let(:file_path_override) { nil }
- it { is_expected.not_to be_nil }
+ after do
+ FileUtils.rm_r(upload_path) if upload_path
+ end
+
+ subject do
+ described_class.from_params(params, :file, [upload_path, Dir.tmpdir], file_path_override)
+ end
- it_behaves_like 'using the remote id',
- filename: 'my_file_.txt',
- content_type: 'application/octet-stream',
- sha256: 'sha256',
- size: 123456,
- remote_id: '1234567890'
+ RSpec.shared_context 'filepath override' do
+ let(:temp_file_override) { Tempfile.new(%w[override override], temp_dir) }
+ let(:file_path_override) { temp_file_override.path }
+
+ before do
+ FileUtils.touch(temp_file_override)
end
- context 'with a path and a remote id' do
+ after do
+ FileUtils.rm_f(temp_file_override)
+ end
+ end
+
+ context 'when valid file is specified' do
+ context 'only local path is specified' do
let(:params) do
- {
- 'file.path' => temp_file.path,
- 'file.name' => 'dir/my file&.txt',
- 'file.sha256' => 'sha256',
- 'file.remote_url' => 'http://localhost/file',
- 'file.remote_id' => '1234567890',
- 'file.etag' => 'etag1234567890',
- 'file.size' => '123456'
- }
+ { 'file.path' => temp_file.path }
end
it { is_expected.not_to be_nil }
- it_behaves_like 'using the remote id',
- filename: 'my_file_.txt',
- content_type: 'application/octet-stream',
- sha256: 'sha256',
- size: 123456,
- remote_id: '1234567890'
+ it "generates filename from path" do
+ expect(subject.original_filename).to eq(::File.basename(temp_file.path))
+ end
end
- context 'with a path override and a remote id' do
- include_context 'filepath override'
+ context 'all parameters are specified' do
+ context 'with a filepath' do
+ let(:params) do
+ { 'file.path' => temp_file.path,
+ 'file.name' => 'dir/my file&.txt',
+ 'file.type' => 'my/type',
+ 'file.sha256' => 'sha256' }
+ end
+
+ it_behaves_like 'using the file path',
+ filename: 'my_file_.txt',
+ content_type: 'my/type',
+ sha256: 'sha256',
+ path_suffix: 'test'
+ end
- let(:params) do
- {
- 'file.name' => 'dir/my file&.txt',
- 'file.sha256' => 'sha256',
- 'file.remote_url' => 'http://localhost/file',
- 'file.remote_id' => '1234567890',
- 'file.etag' => 'etag1234567890',
- 'file.size' => '123456'
- }
+ context 'with a filepath override' do
+ include_context 'filepath override'
+
+ let(:params) do
+ { 'file.path' => temp_file.path,
+ 'file.name' => 'dir/my file&.txt',
+ 'file.type' => 'my/type',
+ 'file.sha256' => 'sha256' }
+ end
+
+ it_behaves_like 'using the file path',
+ filename: 'my_file_.txt',
+ content_type: 'my/type',
+ sha256: 'sha256',
+ path_suffix: 'override'
end
- it { is_expected.not_to be_nil }
+ context 'with a remote id' do
+ let(:params) do
+ {
+ 'file.name' => 'dir/my file&.txt',
+ 'file.sha256' => 'sha256',
+ 'file.remote_url' => 'http://localhost/file',
+ 'file.remote_id' => '1234567890',
+ 'file.etag' => 'etag1234567890',
+ 'file.size' => '123456'
+ }
+ end
+
+ it_behaves_like 'using the remote id',
+ filename: 'my_file_.txt',
+ content_type: 'application/octet-stream',
+ sha256: 'sha256',
+ size: 123456,
+ remote_id: '1234567890'
+ end
- it_behaves_like 'using the remote id',
- filename: 'my_file_.txt',
- content_type: 'application/octet-stream',
- sha256: 'sha256',
- size: 123456,
- remote_id: '1234567890'
+ context 'with a path and a remote id' do
+ let(:params) do
+ {
+ 'file.path' => temp_file.path,
+ 'file.name' => 'dir/my file&.txt',
+ 'file.sha256' => 'sha256',
+ 'file.remote_url' => 'http://localhost/file',
+ 'file.remote_id' => '1234567890',
+ 'file.etag' => 'etag1234567890',
+ 'file.size' => '123456'
+ }
+ end
+
+ it_behaves_like 'using the remote id',
+ filename: 'my_file_.txt',
+ content_type: 'application/octet-stream',
+ sha256: 'sha256',
+ size: 123456,
+ remote_id: '1234567890'
+ end
+
+ context 'with a path override and a remote id' do
+ include_context 'filepath override'
+
+ let(:params) do
+ {
+ 'file.name' => 'dir/my file&.txt',
+ 'file.sha256' => 'sha256',
+ 'file.remote_url' => 'http://localhost/file',
+ 'file.remote_id' => '1234567890',
+ 'file.etag' => 'etag1234567890',
+ 'file.size' => '123456'
+ }
+ end
+
+ it_behaves_like 'using the remote id',
+ filename: 'my_file_.txt',
+ content_type: 'application/octet-stream',
+ sha256: 'sha256',
+ size: 123456,
+ remote_id: '1234567890'
+ end
end
end
- end
- context 'when no params are specified' do
- let(:params) do
- {}
- end
+ context 'when no params are specified' do
+ let(:params) do
+ {}
+ end
- it "does not return an object" do
- is_expected.to be_nil
+ it "does not return an object" do
+ is_expected.to be_nil
+ end
end
- end
- context 'when verifying allowed paths' do
- let(:params) do
- { 'file.path' => temp_file.path }
- end
+ context 'when verifying allowed paths' do
+ let(:params) do
+ { 'file.path' => temp_file.path }
+ end
- context 'when file is stored in system temporary folder' do
- let(:temp_dir) { Dir.tmpdir }
+ context 'when file is stored in system temporary folder' do
+ let(:temp_dir) { Dir.tmpdir }
- it "succeeds" do
- is_expected.not_to be_nil
+ it "succeeds" do
+ is_expected.not_to be_nil
+ end
end
- end
- context 'when file is stored in user provided upload path' do
- let(:upload_path) { Dir.mktmpdir }
- let(:temp_dir) { upload_path }
+ context 'when file is stored in user provided upload path' do
+ let(:upload_path) { Dir.mktmpdir }
+ let(:temp_dir) { upload_path }
- it "succeeds" do
- is_expected.not_to be_nil
+ it "succeeds" do
+ is_expected.not_to be_nil
+ end
end
- end
- context 'when file is stored outside of user provided upload path' do
- let!(:generated_dir) { Dir.mktmpdir }
- let!(:temp_dir) { Dir.mktmpdir }
+ context 'when file is stored outside of user provided upload path' do
+ let!(:generated_dir) { Dir.mktmpdir }
+ let!(:temp_dir) { Dir.mktmpdir }
- before do
- # We overwrite default temporary path
- allow(Dir).to receive(:tmpdir).and_return(generated_dir)
- end
+ before do
+ # We overwrite default temporary path
+ allow(Dir).to receive(:tmpdir).and_return(generated_dir)
+ end
- it "raises an error" do
- expect { subject }.to raise_error(UploadedFile::InvalidPathError, /insecure path used/)
+ it "raises an error" do
+ expect { subject }.to raise_error(UploadedFile::InvalidPathError, /insecure path used/)
+ end
end
end
end
diff --git a/spec/mailers/devise_mailer_spec.rb b/spec/mailers/devise_mailer_spec.rb
index 4637df9c8a3..2ee15308400 100644
--- a/spec/mailers/devise_mailer_spec.rb
+++ b/spec/mailers/devise_mailer_spec.rb
@@ -4,6 +4,9 @@ require 'spec_helper'
require 'email_spec'
RSpec.describe DeviseMailer do
+ include EmailSpec::Matchers
+ include_context 'gitlab email notification'
+
describe "#confirmation_instructions" do
subject { described_class.confirmation_instructions(user, 'faketoken', {}) }
@@ -35,4 +38,30 @@ RSpec.describe DeviseMailer do
end
end
end
+
+ describe '#password_change_by_admin' do
+ subject { described_class.password_change_by_admin(user) }
+
+ let_it_be(:user) { create(:user) }
+
+ 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 /^Password changed by administrator$/i
+ end
+
+ it 'includes the correct content' do
+ is_expected.to have_body_text /An administrator changed the password for your GitLab account/
+ end
+
+ it 'includes a link to GitLab' do
+ is_expected.to have_body_text /#{Gitlab.config.gitlab.url}/
+ end
+ end
end
diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb
index fbbdef5feee..fdff2d837f8 100644
--- a/spec/mailers/emails/profile_spec.rb
+++ b/spec/mailers/emails/profile_spec.rb
@@ -256,4 +256,26 @@ RSpec.describe Emails::Profile do
end
end
end
+
+ describe 'disabled two-factor authentication email' do
+ let_it_be(:user) { create(:user) }
+
+ subject { Notify.disabled_two_factor_email(user) }
+
+ 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 /^Two-factor authentication disabled$/i
+ end
+
+ it 'includes a link to two-factor authentication settings page' do
+ is_expected.to have_body_text /#{profile_two_factor_auth_path}/
+ end
+ end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 7bd1fae8f91..b9f95a9eb00 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -868,36 +868,116 @@ RSpec.describe Notify do
end
end
- def invite_to_project(project, inviter:)
+ def invite_to_project(project, inviter:, user: nil)
create(
:project_member,
:developer,
project: project,
invite_token: '1234',
invite_email: 'toto@example.com',
- user: nil,
+ user: user,
created_by: inviter
)
end
describe 'project invitation' do
let(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } }
- let(:project_member) { invite_to_project(project, inviter: maintainer) }
+ let(:project_member) { invite_to_project(project, inviter: inviter) }
+ let(:inviter) { maintainer }
subject { described_class.member_invited_email('project', project_member.id, project_member.invite_token) }
- 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_behaves_like 'appearance header and footer enabled'
- it_behaves_like 'appearance header and footer not enabled'
+ context 'when invite_email_experiment is disabled' do
+ before do
+ stub_feature_flags(invite_email_experiment: false)
+ end
- it 'contains all the useful information' do
- is_expected.to have_subject "Invitation to join the #{project.full_name} project"
- is_expected.to have_body_text project.full_name
- is_expected.to have_body_text project.full_name
- is_expected.to have_body_text project_member.human_access
- is_expected.to have_body_text project_member.invite_token
+ 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_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+
+ it 'contains all the useful information' do
+ is_expected.to have_subject "Invitation to join the #{project.full_name} project"
+ is_expected.to have_body_text project.full_name
+ is_expected.to have_body_text project_member.human_access
+ is_expected.to have_body_text project_member.invite_token
+ end
+
+ context 'when member is invited via an email address' do
+ it 'does add a param to the invite link' do
+ is_expected.to have_body_text 'new_user_invite=control'
+ end
+
+ it 'tracks an event' do
+ expect(Gitlab::Tracking).to receive(:event).with(
+ 'Growth::Acquisition::Experiment::InviteEmail',
+ 'sent',
+ property: 'control_group'
+ )
+
+ subject.deliver_now
+ end
+ end
+
+ context 'when member is already a user' do
+ let(:project_member) { invite_to_project(project, inviter: maintainer, user: create(:user)) }
+
+ it 'does not add a param to the invite link' do
+ is_expected.not_to have_body_text 'new_user_invite'
+ end
+
+ it 'does not track an event' do
+ expect(Gitlab::Tracking).not_to receive(:event)
+
+ subject.deliver_now
+ end
+ end
+ end
+
+ context 'when invite_email_experiment is enabled' do
+ before do
+ stub_feature_flags(invite_email_experiment: true)
+ end
+
+ 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"
+
+ context 'when there is no inviter' do
+ let(:inviter) { nil }
+
+ it 'contains all the useful information' do
+ is_expected.to have_subject "Invitation to join the #{project.full_name} project"
+ 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
+ end
+ end
+
+ context 'when there is an inviter' do
+ it 'contains all the useful information' do
+ is_expected.to have_subject "#{inviter.name} invited you to join GitLab"
+ 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
+ end
+ end
+
+ it 'adds a param to the invite link' do
+ is_expected.to have_body_text 'new_user_invite=experiment'
+ end
+
+ it 'tracks an event' do
+ expect(Gitlab::Tracking).to receive(:event).with(
+ 'Growth::Acquisition::Experiment::InviteEmail',
+ 'sent',
+ property: 'experiment_group'
+ )
+
+ subject.deliver_now
+ end
end
end
@@ -1416,37 +1496,115 @@ RSpec.describe Notify do
end
end
- def invite_to_group(group, inviter:)
+ def invite_to_group(group, inviter:, user: nil)
create(
:group_member,
:developer,
group: group,
invite_token: '1234',
invite_email: 'toto@example.com',
- user: nil,
+ user: user,
created_by: inviter
)
end
describe 'group invitation' do
let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } }
- let(:group_member) { invite_to_group(group, inviter: owner) }
+ let(:group_member) { invite_to_group(group, inviter: inviter) }
+ let(:inviter) { owner }
subject { described_class.member_invited_email('group', group_member.id, group_member.invite_token) }
- 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_behaves_like 'appearance header and footer enabled'
- it_behaves_like 'appearance header and footer not enabled'
- it_behaves_like 'it requires a group'
+ context 'when invite_email_experiment is disabled' do
+ before do
+ stub_feature_flags(invite_email_experiment: false)
+ end
- it 'contains all the useful information' do
- is_expected.to have_subject "Invitation to join the #{group.name} group"
- is_expected.to have_body_text group.name
- is_expected.to have_body_text group.web_url
- is_expected.to have_body_text group_member.human_access
- is_expected.to have_body_text group_member.invite_token
+ 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_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'it requires a group'
+
+ it 'contains all the useful information' do
+ is_expected.to have_subject "Invitation to join the #{group.name} group"
+ is_expected.to have_body_text group.name
+ is_expected.to have_body_text group.web_url
+ is_expected.to have_body_text group_member.human_access
+ is_expected.to have_body_text group_member.invite_token
+ end
+
+ context 'when member is invited via an email address' do
+ it 'does add a param to the invite link' do
+ is_expected.to have_body_text 'new_user_invite=control'
+ end
+
+ it 'tracks an event' do
+ expect(Gitlab::Tracking).to receive(:event).with(
+ 'Growth::Acquisition::Experiment::InviteEmail',
+ 'sent',
+ property: 'control_group'
+ )
+
+ subject.deliver_now
+ end
+ end
+
+ context 'when member is already a user' do
+ let(:group_member) { invite_to_group(group, inviter: owner, user: create(:user)) }
+
+ it 'does not add a param to the invite link' do
+ is_expected.not_to have_body_text 'new_user_invite'
+ end
+
+ it 'does not track an event' do
+ expect(Gitlab::Tracking).not_to receive(:event)
+
+ subject.deliver_now
+ end
+ end
+ end
+
+ context 'when invite_email_experiment is enabled' do
+ 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_behaves_like 'it requires a group'
+
+ context 'when there is no inviter' do
+ let(:inviter) { nil }
+
+ it 'contains all the useful information' do
+ is_expected.to have_subject "Invitation to join the #{group.name} group"
+ is_expected.to have_body_text group.name
+ is_expected.to have_body_text group_member.human_access.downcase
+ is_expected.to have_body_text group_member.invite_token
+ end
+ end
+
+ context 'when there is an inviter' do
+ it 'contains all the useful information' do
+ is_expected.to have_subject "#{group_member.created_by.name} invited you to join GitLab"
+ is_expected.to have_body_text group.name
+ is_expected.to have_body_text group_member.human_access.downcase
+ is_expected.to have_body_text group_member.invite_token
+ end
+ end
+
+ it 'does add a param to the invite link' do
+ is_expected.to have_body_text 'new_user_invite'
+ end
+
+ it 'tracks an event' do
+ expect(Gitlab::Tracking).to receive(:event).with(
+ 'Growth::Acquisition::Experiment::InviteEmail',
+ 'sent',
+ property: 'experiment_group'
+ )
+
+ subject.deliver_now
+ end
end
end
diff --git a/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb b/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb
index 6c957ee1428..196f5ead8d2 100644
--- a/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb
+++ b/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe MigrateIssueTrackersData do
it 'schedules background migrations at correct time' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(migration_name).to be_scheduled_delayed_migration(3.minutes, jira_service.id, bugzilla_service.id)
diff --git a/spec/migrations/20200122123016_backfill_project_settings_spec.rb b/spec/migrations/20200122123016_backfill_project_settings_spec.rb
index 0992ddde0c1..60c6206a83b 100644
--- a/spec/migrations/20200122123016_backfill_project_settings_spec.rb
+++ b/spec/migrations/20200122123016_backfill_project_settings_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe BackfillProjectSettings, :sidekiq, schema: 20200114113341 do
it 'schedules BackfillProjectSettings background jobs' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 1, 2)
diff --git a/spec/migrations/20200130145430_reschedule_migrate_issue_trackers_data_spec.rb b/spec/migrations/20200130145430_reschedule_migrate_issue_trackers_data_spec.rb
index d5208976928..e140be476ab 100644
--- a/spec/migrations/20200130145430_reschedule_migrate_issue_trackers_data_spec.rb
+++ b/spec/migrations/20200130145430_reschedule_migrate_issue_trackers_data_spec.rb
@@ -53,7 +53,7 @@ RSpec.describe RescheduleMigrateIssueTrackersData do
describe "#up" do
it 'schedules background migrations at correct time' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(migration_name).to be_scheduled_delayed_migration(3.minutes, jira_service.id, bugzilla_service.id)
diff --git a/spec/migrations/20200406102120_backfill_deployment_clusters_from_deployments_spec.rb b/spec/migrations/20200406102120_backfill_deployment_clusters_from_deployments_spec.rb
index 47bc7428286..c6b221b09a5 100644
--- a/spec/migrations/20200406102120_backfill_deployment_clusters_from_deployments_spec.rb
+++ b/spec/migrations/20200406102120_backfill_deployment_clusters_from_deployments_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe BackfillDeploymentClustersFromDeployments, :migration, :sidekiq,
batch_2_end = create_deployment(**deployment_data)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
# batch 1
diff --git a/spec/migrations/20200703125016_backfill_namespace_settings_spec.rb b/spec/migrations/20200703125016_backfill_namespace_settings_spec.rb
index 7b84ef9e236..9ff88009d8a 100644
--- a/spec/migrations/20200703125016_backfill_namespace_settings_spec.rb
+++ b/spec/migrations/20200703125016_backfill_namespace_settings_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe BackfillNamespaceSettings, :sidekiq, schema: 20200703124823 do
it 'schedules BackfillNamespaceSettings background jobs' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 1, 2)
diff --git a/spec/migrations/20200811130433_create_missing_vulnerabilities_issue_links_spec.rb b/spec/migrations/20200811130433_create_missing_vulnerabilities_issue_links_spec.rb
new file mode 100644
index 00000000000..a821e4a43df
--- /dev/null
+++ b/spec/migrations/20200811130433_create_missing_vulnerabilities_issue_links_spec.rb
@@ -0,0 +1,160 @@
+# frozen_string_literal: true
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200811130433_create_missing_vulnerabilities_issue_links.rb')
+
+RSpec.describe CreateMissingVulnerabilitiesIssueLinks, :migration do
+ let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
+ let(:users) { table(:users) }
+ let(:user) { create_user! }
+ let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) }
+ let(:scanners) { table(:vulnerability_scanners) }
+ let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
+ let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') }
+ let(:issues) { table(:issues) }
+ let(:issue1) { issues.create!(id: 123, project_id: project.id) }
+ let(:issue2) { issues.create!(id: 124, project_id: project.id) }
+ let(:issue3) { issues.create!(id: 125, project_id: project.id) }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+ let(:vulnerabilities_findings) { table(:vulnerability_occurrences) }
+ let(:vulnerability_feedback) { table(:vulnerability_feedback) }
+ let(:vulnerability_issue_links) { table(:vulnerability_issue_links) }
+ let(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
+ let(:vulnerability_identifier) { vulnerability_identifiers.create!(project_id: project.id, external_type: 'test 1', external_id: 'test 1', fingerprint: 'test 1', name: 'test 1') }
+ let(:different_vulnerability_identifier) { vulnerability_identifiers.create!(project_id: project.id, external_type: 'test 2', external_id: 'test 2', fingerprint: 'test 2', name: 'test 2') }
+
+ let!(:vulnerability) do
+ create_vulnerability!(
+ project_id: project.id,
+ author_id: user.id
+ )
+ end
+
+ before do
+ create_finding!(
+ vulnerability_id: vulnerability.id,
+ project_id: project.id,
+ scanner_id: scanner.id,
+ primary_identifier_id: vulnerability_identifier.id
+ )
+ create_feedback!(
+ issue_id: issue1.id,
+ project_id: project.id,
+ author_id: user.id
+ )
+
+ # Create a finding with no vulnerability_id
+ # https://gitlab.com/gitlab-com/gl-infra/production/-/issues/2539
+ create_finding!(
+ vulnerability_id: nil,
+ project_id: project.id,
+ scanner_id: different_scanner.id,
+ primary_identifier_id: different_vulnerability_identifier.id,
+ location_fingerprint: 'somewhereinspace',
+ uuid: 'test2'
+ )
+ create_feedback!(
+ category: 2,
+ issue_id: issue2.id,
+ project_id: project.id,
+ author_id: user.id
+ )
+ end
+
+ context 'with no Vulnerabilities::IssueLinks present' do
+ it 'creates missing Vulnerabilities::IssueLinks' do
+ expect(vulnerability_issue_links.count).to eq(0)
+
+ migrate!
+
+ expect(vulnerability_issue_links.count).to eq(1)
+ end
+ end
+
+ context 'when an Vulnerabilities::IssueLink already exists' do
+ before do
+ vulnerability_issue_links.create!(vulnerability_id: vulnerability.id, issue_id: issue1.id)
+ end
+
+ it 'creates no duplicates' do
+ expect(vulnerability_issue_links.count).to eq(1)
+
+ migrate!
+
+ expect(vulnerability_issue_links.count).to eq(1)
+ end
+ end
+
+ context 'when an Vulnerabilities::IssueLink of type created already exists' do
+ before do
+ vulnerability_issue_links.create!(vulnerability_id: vulnerability.id, issue_id: issue3.id, link_type: 2)
+ end
+
+ it 'creates no duplicates' do
+ expect(vulnerability_issue_links.count).to eq(1)
+
+ migrate!
+
+ expect(vulnerability_issue_links.count).to eq(1)
+ end
+ end
+
+ private
+
+ def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0)
+ vulnerabilities.create!(
+ project_id: project_id,
+ author_id: author_id,
+ title: title,
+ severity: severity,
+ confidence: confidence,
+ report_type: report_type
+ )
+ end
+
+ # rubocop:disable Metrics/ParameterLists
+ def create_finding!(
+ vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:,
+ name: "test", severity: 7, confidence: 7, report_type: 0,
+ project_fingerprint: '123qweasdzxc', location_fingerprint: 'test',
+ metadata_version: 'test', raw_metadata: 'test', uuid: 'test')
+ vulnerabilities_findings.create!(
+ vulnerability_id: vulnerability_id,
+ project_id: project_id,
+ name: name,
+ severity: severity,
+ confidence: confidence,
+ report_type: report_type,
+ project_fingerprint: project_fingerprint,
+ scanner_id: scanner.id,
+ primary_identifier_id: vulnerability_identifier.id,
+ location_fingerprint: location_fingerprint,
+ metadata_version: metadata_version,
+ raw_metadata: raw_metadata,
+ uuid: uuid
+ )
+ end
+ # rubocop:enable Metrics/ParameterLists
+
+ # project_fingerprint on Vulnerabilities::Finding is a bytea and we need to match this
+ def create_feedback!(issue_id:, project_id:, author_id:, feedback_type: 1, category: 0, project_fingerprint: '3132337177656173647a7863')
+ vulnerability_feedback.create!(
+ feedback_type: feedback_type,
+ issue_id: issue_id,
+ category: category,
+ project_fingerprint: project_fingerprint,
+ project_id: project_id,
+ author_id: author_id
+ )
+ end
+
+ def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.now, confirmed_at: Time.now)
+ users.create!(
+ name: name,
+ email: email,
+ username: name,
+ projects_limit: 0,
+ user_type: user_type,
+ confirmed_at: confirmed_at
+ )
+ end
+end
diff --git a/spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb b/spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb
new file mode 100644
index 00000000000..20ba2fbccea
--- /dev/null
+++ b/spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200915044225_schedule_migration_to_hashed_storage.rb')
+
+RSpec.describe ScheduleMigrationToHashedStorage, :sidekiq do
+ describe '#up' do
+ it 'schedules background migration job' do
+ Sidekiq::Testing.fake! do
+ expect { migrate! }.to change { BackgroundMigrationWorker.jobs.size }.by(1)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/backfill_imported_snippet_repositories_spec.rb b/spec/migrations/backfill_imported_snippet_repositories_spec.rb
index 208bda274e2..998a691bf77 100644
--- a/spec/migrations/backfill_imported_snippet_repositories_spec.rb
+++ b/spec/migrations/backfill_imported_snippet_repositories_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe BackfillImportedSnippetRepositories do
create_snippet(10)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(described_class::MIGRATION)
diff --git a/spec/migrations/backfill_snippet_repositories_spec.rb b/spec/migrations/backfill_snippet_repositories_spec.rb
index 084faa16a37..6d417669ad6 100644
--- a/spec/migrations/backfill_snippet_repositories_spec.rb
+++ b/spec/migrations/backfill_snippet_repositories_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe BackfillSnippetRepositories do
stub_const("#{described_class.name}::BATCH_SIZE", 2)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(described_class::MIGRATION)
diff --git a/spec/migrations/complete_namespace_settings_migration_spec.rb b/spec/migrations/complete_namespace_settings_migration_spec.rb
new file mode 100644
index 00000000000..7820536f355
--- /dev/null
+++ b/spec/migrations/complete_namespace_settings_migration_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200907124300_complete_namespace_settings_migration.rb')
+
+RSpec.describe CompleteNamespaceSettingsMigration, :redis do
+ let(:migration) { spy('migration') }
+
+ context 'when still legacy artifacts exist' do
+ let(:namespaces) { table(:namespaces) }
+ let(:namespace_settings) { table(:namespace_settings) }
+ let!(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') }
+
+ it 'steals sidekiq jobs from BackfillNamespaceSettings background migration' do
+ expect(Gitlab::BackgroundMigration).to receive(:steal).with('BackfillNamespaceSettings')
+
+ migrate!
+ end
+
+ it 'migrates namespaces without namespace_settings' do
+ expect { migrate! }.to change { namespace_settings.count }.from(0).to(1)
+ end
+ end
+end
diff --git a/spec/migrations/enqueue_reset_merge_status_second_run_spec.rb b/spec/migrations/enqueue_reset_merge_status_second_run_spec.rb
index f5728534675..d4189326366 100644
--- a/spec/migrations/enqueue_reset_merge_status_second_run_spec.rb
+++ b/spec/migrations/enqueue_reset_merge_status_second_run_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe EnqueueResetMergeStatusSecondRun do
stub_const("#{described_class.name}::BATCH_SIZE", 2)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(described_class::MIGRATION)
diff --git a/spec/migrations/enqueue_reset_merge_status_spec.rb b/spec/migrations/enqueue_reset_merge_status_spec.rb
index 683d2caf9ca..c581293ddcd 100644
--- a/spec/migrations/enqueue_reset_merge_status_spec.rb
+++ b/spec/migrations/enqueue_reset_merge_status_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe EnqueueResetMergeStatus do
stub_const("#{described_class.name}::BATCH_SIZE", 2)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(described_class::MIGRATION)
diff --git a/spec/migrations/ensure_filled_external_diff_store_on_merge_request_diffs_spec.rb b/spec/migrations/ensure_filled_external_diff_store_on_merge_request_diffs_spec.rb
new file mode 100644
index 00000000000..808580d5770
--- /dev/null
+++ b/spec/migrations/ensure_filled_external_diff_store_on_merge_request_diffs_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200910170908_ensure_filled_external_diff_store_on_merge_request_diffs.rb')
+
+RSpec.describe EnsureFilledExternalDiffStoreOnMergeRequestDiffs, schema: 20200908095446 do
+ let!(:merge_request_diffs) { table(:merge_request_diffs) }
+ let!(:merge_requests) { table(:merge_requests) }
+ let!(:namespaces) { table(:namespaces) }
+ let!(:projects) { table(:projects) }
+ let!(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
+ let!(:project) { projects.create!(namespace_id: namespace.id) }
+ let!(:merge_request) { merge_requests.create!(source_branch: 'x', target_branch: 'master', target_project_id: project.id) }
+
+ before do
+ constraint_name = 'check_93ee616ac9'
+
+ # In order to insert a row with a NULL to fill.
+ ActiveRecord::Base.connection.execute "ALTER TABLE merge_request_diffs DROP CONSTRAINT #{constraint_name}"
+
+ @external_diff_store_1 = merge_request_diffs.create!(external_diff_store: 1, merge_request_id: merge_request.id)
+ @external_diff_store_2 = merge_request_diffs.create!(external_diff_store: 2, merge_request_id: merge_request.id)
+ @external_diff_store_nil = merge_request_diffs.create!(external_diff_store: nil, merge_request_id: merge_request.id)
+
+ # revert DB structure
+ ActiveRecord::Base.connection.execute "ALTER TABLE merge_request_diffs ADD CONSTRAINT #{constraint_name} CHECK ((external_diff_store IS NOT NULL)) NOT VALID"
+ end
+
+ it 'correctly migrates nil external_diff_store to 1' do
+ migrate!
+
+ @external_diff_store_1.reload
+ @external_diff_store_2.reload
+ @external_diff_store_nil.reload
+
+ expect(@external_diff_store_1.external_diff_store).to eq(1) # unchanged
+ expect(@external_diff_store_2.external_diff_store).to eq(2) # unchanged
+ expect(@external_diff_store_nil.external_diff_store).to eq(1) # nil => 1
+ end
+end
diff --git a/spec/migrations/ensure_target_project_id_is_filled_spec.rb b/spec/migrations/ensure_target_project_id_is_filled_spec.rb
new file mode 100644
index 00000000000..72d59a72814
--- /dev/null
+++ b/spec/migrations/ensure_target_project_id_is_filled_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200831065705_ensure_target_project_id_is_filled.rb')
+
+RSpec.describe EnsureTargetProjectIdIsFilled, schema: 20200827085101 do
+ let_it_be(:namespaces) { table(:namespaces) }
+ let_it_be(:projects) { table(:projects) }
+ let_it_be(:merge_requests) { table(:merge_requests) }
+ let_it_be(:metrics) { table(:merge_request_metrics) }
+
+ let!(:namespace) { namespaces.create!(name: 'namespace', path: 'namespace') }
+ let!(:project_1) { projects.create!(namespace_id: namespace.id) }
+ let!(:project_2) { projects.create!(namespace_id: namespace.id) }
+ let!(:merge_request_to_migrate_1) { merge_requests.create!(source_branch: 'a', target_branch: 'b', target_project_id: project_1.id) }
+ let!(:merge_request_to_migrate_2) { merge_requests.create!(source_branch: 'c', target_branch: 'd', target_project_id: project_2.id) }
+ let!(:merge_request_not_to_migrate) { merge_requests.create!(source_branch: 'e', target_branch: 'f', target_project_id: project_1.id) }
+
+ let!(:metrics_1) { metrics.create!(merge_request_id: merge_request_to_migrate_1.id) }
+ let!(:metrics_2) { metrics.create!(merge_request_id: merge_request_to_migrate_2.id) }
+ let!(:metrics_3) { metrics.create!(merge_request_id: merge_request_not_to_migrate.id, target_project_id: project_1.id) }
+
+ it 'migrates missing target_project_ids' do
+ migrate!
+
+ expect(metrics_1.reload.target_project_id).to eq(project_1.id)
+ expect(metrics_2.reload.target_project_id).to eq(project_2.id)
+ expect(metrics_3.reload.target_project_id).to eq(project_1.id)
+ end
+end
diff --git a/spec/migrations/fix_projects_without_project_feature_spec.rb b/spec/migrations/fix_projects_without_project_feature_spec.rb
index 2a4ba3f3ca5..1ec67c07ba3 100644
--- a/spec/migrations/fix_projects_without_project_feature_spec.rb
+++ b/spec/migrations/fix_projects_without_project_feature_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe FixProjectsWithoutProjectFeature do
around do |example|
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
example.call
end
end
diff --git a/spec/migrations/fix_projects_without_prometheus_services_spec.rb b/spec/migrations/fix_projects_without_prometheus_services_spec.rb
index 73ede2ad90c..a4504ab6773 100644
--- a/spec/migrations/fix_projects_without_prometheus_services_spec.rb
+++ b/spec/migrations/fix_projects_without_prometheus_services_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe FixProjectsWithoutPrometheusService, :migration do
around do |example|
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
example.call
end
end
diff --git a/spec/migrations/fix_wrong_pages_access_level_spec.rb b/spec/migrations/fix_wrong_pages_access_level_spec.rb
index 9ee5fa783a4..80787e0f115 100644
--- a/spec/migrations/fix_wrong_pages_access_level_spec.rb
+++ b/spec/migrations/fix_wrong_pages_access_level_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe FixWrongPagesAccessLevel, :sidekiq_might_not_need_inline, schema:
it 'correctly schedules background migrations' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
first_id = create_project_feature("project1", project_class::PRIVATE, feature_class::PRIVATE).id
last_id = create_project_feature("project2", project_class::PRIVATE, feature_class::PUBLIC).id
diff --git a/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb b/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb
index e3119be495d..92a49046193 100644
--- a/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb
+++ b/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb
@@ -53,7 +53,7 @@ RSpec.describe MigrateDiscussionIdOnPromotedEpics do
stub_const("#{described_class.name}::BATCH_SIZE", 2)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(migration_name).to be_scheduled_delayed_migration(2.minutes, %w(id1 id2))
@@ -69,7 +69,7 @@ RSpec.describe MigrateDiscussionIdOnPromotedEpics do
create_note(create_epic, 'id3')
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(migration_name).to be_scheduled_delayed_migration(2.minutes, %w(id1))
diff --git a/spec/migrations/move_limits_from_plans_spec.rb b/spec/migrations/move_limits_from_plans_spec.rb
index 16f94ba6dbb..a7a7d540ed3 100644
--- a/spec/migrations/move_limits_from_plans_spec.rb
+++ b/spec/migrations/move_limits_from_plans_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe MoveLimitsFromPlans do
let(:plans) { table(:plans) }
let(:plan_limits) { table(:plan_limits) }
- let!(:early_adopter_plan) { plans.create(name: 'early_adopter', title: 'Early adopter', active_pipelines_limit: 10, pipeline_size_limit: 11, active_jobs_limit: 12) }
let!(:gold_plan) { plans.create(name: 'gold', title: 'Gold', active_pipelines_limit: 20, pipeline_size_limit: 21, active_jobs_limit: 22) }
let!(:silver_plan) { plans.create(name: 'silver', title: 'Silver', active_pipelines_limit: 30, pipeline_size_limit: 31, active_jobs_limit: 32) }
let!(:bronze_plan) { plans.create(name: 'bronze', title: 'Bronze', active_pipelines_limit: 40, pipeline_size_limit: 41, active_jobs_limit: 42) }
@@ -16,7 +15,7 @@ RSpec.describe MoveLimitsFromPlans do
describe 'migrate' do
it 'populates plan_limits from all the records in plans' do
- expect { migrate! }.to change { plan_limits.count }.by 6
+ expect { migrate! }.to change { plan_limits.count }.by 5
end
it 'copies plan limits and plan.id into to plan_limits table' do
@@ -24,7 +23,6 @@ RSpec.describe MoveLimitsFromPlans do
new_data = plan_limits.pluck(:plan_id, :ci_active_pipelines, :ci_pipeline_size, :ci_active_jobs)
expected_data = [
- [early_adopter_plan.id, 10, 11, 12],
[gold_plan.id, 20, 21, 22],
[silver_plan.id, 30, 31, 32],
[bronze_plan.id, 40, 41, 42],
diff --git a/spec/migrations/schedule_calculate_wiki_sizes_spec.rb b/spec/migrations/schedule_calculate_wiki_sizes_spec.rb
index 39a93f3ed25..0af491d863b 100644
--- a/spec/migrations/schedule_calculate_wiki_sizes_spec.rb
+++ b/spec/migrations/schedule_calculate_wiki_sizes_spec.rb
@@ -16,12 +16,12 @@ RSpec.describe ScheduleCalculateWikiSizes do
let(:project3) { projects.create!(name: 'wiki-project-3', path: 'wiki-project-3', namespace_id: namespace.id) }
context 'when missing wiki sizes exist' do
- let!(:project_statistic1) { project_statistics.create!(id: 1, project_id: project1.id, namespace_id: namespace.id, wiki_size: 1000) }
- let!(:project_statistic2) { project_statistics.create!(id: 2, project_id: project2.id, namespace_id: namespace.id, wiki_size: nil) }
- let!(:project_statistic3) { project_statistics.create!(id: 3, project_id: project3.id, namespace_id: namespace.id, wiki_size: nil) }
+ let!(:project_statistic1) { project_statistics.create!(project_id: project1.id, namespace_id: namespace.id, wiki_size: 1000) }
+ let!(:project_statistic2) { project_statistics.create!(project_id: project2.id, namespace_id: namespace.id, wiki_size: nil) }
+ let!(:project_statistic3) { project_statistics.create!(project_id: project3.id, namespace_id: namespace.id, wiki_size: nil) }
it 'schedules a background migration' do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(migration_name).to be_scheduled_delayed_migration(5.minutes, project_statistic2.id, project_statistic3.id)
@@ -44,12 +44,12 @@ RSpec.describe ScheduleCalculateWikiSizes do
before do
namespace = namespaces.create!(name: 'wiki-migration', path: 'wiki-migration')
project = projects.create!(name: 'wiki-project-1', path: 'wiki-project-1', namespace_id: namespace.id)
- project_statistics.create!(project_id: project.id, namespace_id: 1, wiki_size: 1000)
+ project_statistics.create!(project_id: project.id, namespace_id: namespace.id, wiki_size: 1000)
end
it 'does not schedule a background migration' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq 0
diff --git a/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb b/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb
index a51bc374d5f..539b9ee96a8 100644
--- a/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb
+++ b/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe ScheduleFillValidTimeForPagesDomainCertificates do
it 'correctly schedules background migrations' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
first_id = domains_table.find_by_domain("domain3.example.com").id
diff --git a/spec/migrations/schedule_migrate_security_scans_spec.rb b/spec/migrations/schedule_migrate_security_scans_spec.rb
index 4fd17b20666..61b14f239ae 100644
--- a/spec/migrations/schedule_migrate_security_scans_spec.rb
+++ b/spec/migrations/schedule_migrate_security_scans_spec.rb
@@ -40,7 +40,7 @@ RSpec.describe ScheduleMigrateSecurityScans, :sidekiq do
it 'schedules migration of security scans' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migration.up
expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, job_artifact_1.id, job_artifact_1.id)
@@ -57,7 +57,7 @@ RSpec.describe ScheduleMigrateSecurityScans, :sidekiq do
it 'schedules migration of security scans' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migration.up
expect(BackgroundMigrationWorker.jobs).to be_empty
diff --git a/spec/migrations/schedule_pages_metadata_migration_spec.rb b/spec/migrations/schedule_pages_metadata_migration_spec.rb
index c37e19eb71c..94311237cf4 100644
--- a/spec/migrations/schedule_pages_metadata_migration_spec.rb
+++ b/spec/migrations/schedule_pages_metadata_migration_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe SchedulePagesMetadataMigration do
it 'schedules pages metadata migration' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 111, 111)
diff --git a/spec/migrations/schedule_populate_merge_request_assignees_table_spec.rb b/spec/migrations/schedule_populate_merge_request_assignees_table_spec.rb
index 8b26cd589fd..93185b330c7 100644
--- a/spec/migrations/schedule_populate_merge_request_assignees_table_spec.rb
+++ b/spec/migrations/schedule_populate_merge_request_assignees_table_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe SchedulePopulateMergeRequestAssigneesTable do
stub_const("#{described_class.name}::BATCH_SIZE", 2)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(described_class::MIGRATION)
diff --git a/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb b/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb
index ce618449884..8678361fc64 100644
--- a/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb
+++ b/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe SchedulePopulatePersonalSnippetStatistics do
stub_const("#{described_class}::BATCH_SIZE", 4)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
aggregate_failures do
diff --git a/spec/migrations/schedule_populate_project_snippet_statistics_spec.rb b/spec/migrations/schedule_populate_project_snippet_statistics_spec.rb
index 05e9d4d2f79..d5f048ed5cb 100644
--- a/spec/migrations/schedule_populate_project_snippet_statistics_spec.rb
+++ b/spec/migrations/schedule_populate_project_snippet_statistics_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe SchedulePopulateProjectSnippetStatistics do
stub_const("#{described_class}::BATCH_SIZE", 4)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
aggregate_failures do
diff --git a/spec/migrations/schedule_populate_user_highest_roles_table_spec.rb b/spec/migrations/schedule_populate_user_highest_roles_table_spec.rb
index c7e5c6f30a6..32def8ab47d 100644
--- a/spec/migrations/schedule_populate_user_highest_roles_table_spec.rb
+++ b/spec/migrations/schedule_populate_user_highest_roles_table_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe SchedulePopulateUserHighestRolesTable do
stub_const("#{described_class.name}::BATCH_SIZE", 2)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 1, 4)
diff --git a/spec/migrations/schedule_recalculate_project_authorizations_second_run_spec.rb b/spec/migrations/schedule_recalculate_project_authorizations_second_run_spec.rb
index 06f1a7e28eb..a02e00de1e3 100644
--- a/spec/migrations/schedule_recalculate_project_authorizations_second_run_spec.rb
+++ b/spec/migrations/schedule_recalculate_project_authorizations_second_run_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe ScheduleRecalculateProjectAuthorizationsSecondRun do
it 'schedules background migration' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(2)
diff --git a/spec/migrations/schedule_recalculate_project_authorizations_spec.rb b/spec/migrations/schedule_recalculate_project_authorizations_spec.rb
index 9519b103284..378e6aa133d 100644
--- a/spec/migrations/schedule_recalculate_project_authorizations_spec.rb
+++ b/spec/migrations/schedule_recalculate_project_authorizations_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe ScheduleRecalculateProjectAuthorizations do
it 'schedules background migration' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(2)
@@ -45,7 +45,7 @@ RSpec.describe ScheduleRecalculateProjectAuthorizations do
access_level: 30)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(2)
diff --git a/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb b/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb
index 300bb940dd8..5328abf7ea7 100644
--- a/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb
+++ b/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe ScheduleRecalculateProjectAuthorizationsThirdRun do
it 'schedules background migration' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(2)
diff --git a/spec/migrations/schedule_sync_issuables_state_id_spec.rb b/spec/migrations/schedule_sync_issuables_state_id_spec.rb
index ecfebbde348..d22d636e084 100644
--- a/spec/migrations/schedule_sync_issuables_state_id_spec.rb
+++ b/spec/migrations/schedule_sync_issuables_state_id_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe ScheduleSyncIssuablesStateId do
it 'correctly schedules issuable sync background migration' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(migration).to be_scheduled_delayed_migration(120.seconds, resource_1.id, resource_2.id)
diff --git a/spec/migrations/schedule_sync_issuables_state_id_where_nil_spec.rb b/spec/migrations/schedule_sync_issuables_state_id_where_nil_spec.rb
index d23f5b69d22..3d450f0e6bc 100644
--- a/spec/migrations/schedule_sync_issuables_state_id_where_nil_spec.rb
+++ b/spec/migrations/schedule_sync_issuables_state_id_where_nil_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe ScheduleSyncIssuablesStateIdWhereNil do
it 'correctly schedules issuable sync background migration' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(migration).to be_scheduled_delayed_migration(120.seconds, resource_1.id, resource_3.id)
diff --git a/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb b/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb
index 949d8d8794f..deb7aae737a 100644
--- a/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb
+++ b/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent do
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
@@ -30,7 +30,7 @@ RSpec.describe ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent do
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
@@ -47,7 +47,7 @@ RSpec.describe ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent do
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
@@ -66,7 +66,7 @@ RSpec.describe ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent do
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(2)
diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb
index f937a879400..eb9dcca842d 100644
--- a/spec/models/alert_management/alert_spec.rb
+++ b/spec/models/alert_management/alert_spec.rb
@@ -332,35 +332,39 @@ RSpec.describe AlertManagement::Alert do
end
end
- describe '#details' do
- let(:payload) do
- {
- 'title' => 'Details title',
- 'custom' => {
- 'alert' => {
- 'fields' => %w[one two]
- }
- },
- 'yet' => {
- 'another' => 'field'
- }
- }
- end
-
- let(:alert) { build(:alert_management_alert, project: project, title: 'Details title', payload: payload) }
-
- subject { alert.details }
-
- it 'renders the payload as inline hash' do
- is_expected.to eq(
- 'custom.alert.fields' => %w[one two],
- 'yet.another' => 'field'
- )
+ describe '.reference_pattern' do
+ subject { described_class.reference_pattern }
+
+ it { is_expected.to match('gitlab-org/gitlab^alert#123') }
+ end
+
+ describe '.link_reference_pattern' do
+ subject { described_class.link_reference_pattern }
+
+ it { is_expected.to match(triggered_alert.details_url) }
+ it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab/alert_management/123") }
+ it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab/issues/123") }
+ it { is_expected.not_to match("gitlab-org/gitlab/-/alert_management/123") }
+ end
+
+ describe '.reference_valid?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:ref, :result) do
+ '123456' | true
+ '1' | true
+ '-1' | false
+ nil | false
+ '123456891012345678901234567890' | false
+ end
+
+ with_them do
+ it { expect(described_class.reference_valid?(ref)).to eq(result) }
end
end
describe '#to_reference' do
- it { expect(triggered_alert.to_reference).to eq('') }
+ it { expect(triggered_alert.to_reference).to eq("^alert##{triggered_alert.iid}") }
end
describe '#trigger' do
@@ -449,22 +453,4 @@ RSpec.describe AlertManagement::Alert do
expect { subject }.to change { alert.events }.by(1)
end
end
-
- describe '#present' do
- context 'when alert is generic' do
- let(:alert) { triggered_alert }
-
- it 'uses generic alert presenter' do
- expect(alert.present).to be_kind_of(AlertManagement::AlertPresenter)
- end
- end
-
- context 'when alert is Prometheus specific' do
- let(:alert) { build(:alert_management_alert, :prometheus, project: project) }
-
- it 'uses Prometheus Alert presenter' do
- expect(alert.present).to be_kind_of(AlertManagement::PrometheusAlertPresenter)
- end
- end
- end
end
diff --git a/spec/models/analytics/instance_statistics/measurement_spec.rb b/spec/models/analytics/instance_statistics/measurement_spec.rb
new file mode 100644
index 00000000000..4df847ea524
--- /dev/null
+++ b/spec/models/analytics/instance_statistics/measurement_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
+ describe 'validation' do
+ let!(:measurement) { create(:instance_statistics_measurement) }
+
+ it { is_expected.to validate_presence_of(:recorded_at) }
+ it { is_expected.to validate_presence_of(:identifier) }
+ it { is_expected.to validate_presence_of(:count) }
+ it { is_expected.to validate_uniqueness_of(:recorded_at).scoped_to(:identifier) }
+ end
+
+ describe 'identifiers enum' do
+ it 'maps to the correct values' do
+ expect(described_class.identifiers).to eq({
+ projects: 1,
+ users: 2,
+ issues: 3,
+ merge_requests: 4,
+ groups: 5,
+ pipelines: 6
+ }.with_indifferent_access)
+ end
+ end
+
+ describe 'scopes' do
+ let_it_be(:measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) }
+ let_it_be(:measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) }
+ let_it_be(:measurement_3) { create(:instance_statistics_measurement, :group_count, recorded_at: 5.days.ago) }
+
+ describe '.order_by_latest' do
+ subject { described_class.order_by_latest }
+
+ it { is_expected.to eq([measurement_2, measurement_3, measurement_1]) }
+ end
+
+ describe '.with_identifier' do
+ subject { described_class.with_identifier(:projects) }
+
+ it { is_expected.to match_array([measurement_1, measurement_2]) }
+ end
+ end
+end
diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb
index d9ab326505b..5ea1907543a 100644
--- a/spec/models/application_record_spec.rb
+++ b/spec/models/application_record_spec.rb
@@ -30,49 +30,51 @@ RSpec.describe ApplicationRecord do
end
end
- describe '.safe_find_or_create_by' do
- it 'creates the user avoiding race conditions' do
- expect(Suggestion).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique)
- allow(Suggestion).to receive(:find_or_create_by).and_call_original
+ context 'safe find or create methods' do
+ let_it_be(:note) { create(:diff_note_on_merge_request) }
- expect { Suggestion.safe_find_or_create_by(build(:suggestion).attributes) }
- .to change { Suggestion.count }.by(1)
- end
+ let(:suggestion_attributes) { attributes_for(:suggestion).merge!(note_id: note.id) }
- it 'passes a block to find_or_create_by' do
- attributes = build(:suggestion).attributes
+ describe '.safe_find_or_create_by' do
+ it 'creates the suggestion avoiding race conditions' do
+ expect(Suggestion).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique)
+ allow(Suggestion).to receive(:find_or_create_by).and_call_original
- expect do |block|
- Suggestion.safe_find_or_create_by(attributes, &block)
- end.to yield_with_args(an_object_having_attributes(attributes))
- end
+ expect { Suggestion.safe_find_or_create_by(suggestion_attributes) }
+ .to change { Suggestion.count }.by(1)
+ end
- it 'does not create a record when is not valid' do
- raw_usage_data = RawUsageData.safe_find_or_create_by({ recorded_at: nil })
+ it 'passes a block to find_or_create_by' do
+ expect do |block|
+ Suggestion.safe_find_or_create_by(suggestion_attributes, &block)
+ end.to yield_with_args(an_object_having_attributes(suggestion_attributes))
+ end
- expect(raw_usage_data.id).to be_nil
- expect(raw_usage_data).not_to be_valid
- end
- end
+ it 'does not create a record when is not valid' do
+ raw_usage_data = RawUsageData.safe_find_or_create_by({ recorded_at: nil })
- describe '.safe_find_or_create_by!' do
- it 'creates a record using safe_find_or_create_by' do
- expect(Suggestion).to receive(:find_or_create_by).and_call_original
-
- expect(Suggestion.safe_find_or_create_by!(build(:suggestion).attributes))
- .to be_a(Suggestion)
+ expect(raw_usage_data.id).to be_nil
+ expect(raw_usage_data).not_to be_valid
+ end
end
- it 'raises a validation error if the record was not persisted' do
- expect { Suggestion.find_or_create_by!(note: nil) }.to raise_error(ActiveRecord::RecordInvalid)
- end
+ describe '.safe_find_or_create_by!' do
+ it 'creates a record using safe_find_or_create_by' do
+ expect(Suggestion).to receive(:find_or_create_by).and_call_original
+
+ expect(Suggestion.safe_find_or_create_by!(suggestion_attributes))
+ .to be_a(Suggestion)
+ end
- it 'passes a block to find_or_create_by' do
- attributes = build(:suggestion).attributes
+ it 'raises a validation error if the record was not persisted' do
+ expect { Suggestion.find_or_create_by!(note: nil) }.to raise_error(ActiveRecord::RecordInvalid)
+ end
- expect do |block|
- Suggestion.safe_find_or_create_by!(attributes, &block)
- end.to yield_with_args(an_object_having_attributes(attributes))
+ it 'passes a block to find_or_create_by' do
+ expect do |block|
+ Suggestion.safe_find_or_create_by!(suggestion_attributes, &block)
+ end.to yield_with_args(an_object_having_attributes(suggestion_attributes))
+ end
end
end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index ab25608e2f0..9f76fb3330d 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -71,6 +71,8 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value('three').for(:push_event_activities_limit) }
it { is_expected.not_to allow_value(nil).for(:push_event_activities_limit) }
+ it { is_expected.to validate_numericality_of(:container_registry_delete_tags_service_timeout).only_integer.is_greater_than_or_equal_to(0) }
+
it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) }
it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) }
it { is_expected.to validate_presence_of(:max_artifacts_size) }
@@ -189,14 +191,10 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value(nil).for(:snowplow_collector_hostname) }
it { is_expected.to allow_value("snowplow.gitlab.com").for(:snowplow_collector_hostname) }
it { is_expected.not_to allow_value('/example').for(:snowplow_collector_hostname) }
- it { is_expected.to allow_value('https://example.org').for(:snowplow_iglu_registry_url) }
- it { is_expected.not_to allow_value('not-a-url').for(:snowplow_iglu_registry_url) }
- it { is_expected.to allow_value(nil).for(:snowplow_iglu_registry_url) }
end
context 'when snowplow is not enabled' do
it { is_expected.to allow_value(nil).for(:snowplow_collector_hostname) }
- it { is_expected.to allow_value(nil).for(:snowplow_iglu_registry_url) }
end
context "when user accepted let's encrypt terms of service" do
@@ -640,6 +638,29 @@ RSpec.describe ApplicationSetting do
is_expected.to be_invalid
end
end
+
+ context 'gitpod settings' do
+ it 'is invalid if gitpod is enabled and no url is provided' do
+ allow(subject).to receive(:gitpod_enabled).and_return(true)
+ allow(subject).to receive(:gitpod_url).and_return(nil)
+
+ is_expected.to be_invalid
+ end
+
+ it 'is invalid if gitpod is enabled and an empty url is provided' do
+ allow(subject).to receive(:gitpod_enabled).and_return(true)
+ allow(subject).to receive(:gitpod_url).and_return('')
+
+ is_expected.to be_invalid
+ end
+
+ it 'is invalid if gitpod is enabled and an invalid url is provided' do
+ allow(subject).to receive(:gitpod_enabled).and_return(true)
+ allow(subject).to receive(:gitpod_url).and_return('javascript:alert("test")//')
+
+ is_expected.to be_invalid
+ end
+ end
end
context 'restrict creating duplicates' do
diff --git a/spec/models/atlassian/identity_spec.rb b/spec/models/atlassian/identity_spec.rb
new file mode 100644
index 00000000000..a1dfe5b0e51
--- /dev/null
+++ b/spec/models/atlassian/identity_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::Identity do
+ describe 'associations' do
+ it { is_expected.to belong_to(:user) }
+ end
+
+ describe 'validations' do
+ subject { create(:atlassian_identity) }
+
+ it { is_expected.to validate_presence_of(:extern_uid) }
+ it { is_expected.to validate_uniqueness_of(:extern_uid) }
+ it { is_expected.to validate_presence_of(:user) }
+ it { is_expected.to validate_uniqueness_of(:user) }
+ end
+
+ describe 'encrypted tokens' do
+ let(:token) { SecureRandom.alphanumeric(1254) }
+ let(:refresh_token) { SecureRandom.alphanumeric(45) }
+ let(:identity) { create(:atlassian_identity, token: token, refresh_token: refresh_token) }
+
+ it 'saves the encrypted token, refresh token and corresponding ivs' do
+ expect(identity.encrypted_token).not_to be_nil
+ expect(identity.encrypted_token_iv).not_to be_nil
+ expect(identity.encrypted_refresh_token).not_to be_nil
+ expect(identity.encrypted_refresh_token_iv).not_to be_nil
+
+ expect(identity.token).to eq(token)
+ expect(identity.refresh_token).to eq(refresh_token)
+ end
+ end
+end
diff --git a/spec/models/audit_event_partitioned_spec.rb b/spec/models/audit_event_partitioned_spec.rb
index fe69f0083b7..ab48e291f78 100644
--- a/spec/models/audit_event_partitioned_spec.rb
+++ b/spec/models/audit_event_partitioned_spec.rb
@@ -7,7 +7,10 @@ RSpec.describe AuditEventPartitioned do
let(:partitioned_table) { described_class }
it 'has the same columns as the source table' do
- expect(partitioned_table.column_names).to match_array(source_table.column_names)
+ column_names_from_source_table = column_names(source_table)
+ column_names_from_partioned_table = column_names(partitioned_table)
+
+ expect(column_names_from_partioned_table).to match_array(column_names_from_source_table)
end
it 'has the same null constraints as the source table' do
@@ -30,6 +33,14 @@ RSpec.describe AuditEventPartitioned do
expect(event_from_partitioned_table).to eq(event_from_source_table)
end
+ def column_names(table)
+ table.connection.select_all(<<~SQL)
+ SELECT c.column_name
+ FROM information_schema.columns c
+ WHERE c.table_name = '#{table.table_name}'
+ SQL
+ end
+
def null_constraints(table)
table.connection.select_all(<<~SQL)
SELECT c.column_name, c.is_nullable
diff --git a/spec/models/authentication_event_spec.rb b/spec/models/authentication_event_spec.rb
new file mode 100644
index 00000000000..56b0111f2c7
--- /dev/null
+++ b/spec/models/authentication_event_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AuthenticationEvent do
+ describe 'associations' do
+ it { is_expected.to belong_to(:user).optional }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:provider) }
+ it { is_expected.to validate_presence_of(:user_name) }
+ it { is_expected.to validate_presence_of(:result) }
+ end
+end
diff --git a/spec/models/badges/project_badge_spec.rb b/spec/models/badges/project_badge_spec.rb
index 9b9836129a6..78bfd4e18c0 100644
--- a/spec/models/badges/project_badge_spec.rb
+++ b/spec/models/badges/project_badge_spec.rb
@@ -27,8 +27,8 @@ RSpec.describe ProjectBadge do
end
context 'methods' do
- let(:badge) { build(:project_badge, link_url: placeholder_url, image_url: placeholder_url) }
- let!(:project) { badge.project }
+ let(:badge) { build_stubbed(:project_badge, link_url: placeholder_url, image_url: placeholder_url) }
+ let(:project) { badge.project }
describe '#rendered_link_url' do
let(:method) { :link_url }
diff --git a/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb b/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb
index 057f0f32158..84dfc5186a8 100644
--- a/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb
+++ b/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb
@@ -9,119 +9,228 @@ RSpec.describe BlobViewer::MetricsDashboardYml do
let_it_be(:project) { create(:project, :repository) }
let(:blob) { fake_blob(path: '.gitlab/dashboards/custom-dashboard.yml', data: data) }
let(:sha) { sample_commit.id }
+ let(:data) { fixture_file('lib/gitlab/metrics/dashboard/sample_dashboard.yml') }
subject(:viewer) { described_class.new(blob) }
- context 'when the definition is valid' do
- let(:data) { File.read(Rails.root.join('config/prometheus/common_metrics.yml')) }
+ context 'with metrics_dashboard_exhaustive_validations feature flag on' do
+ before do
+ stub_feature_flags(metrics_dashboard_exhaustive_validations: true)
+ end
+
+ context 'when the definition is valid' do
+ before do
+ allow(Gitlab::Metrics::Dashboard::Validator).to receive(:errors).and_return([])
+ end
+
+ describe '#valid?' do
+ it 'calls prepare! on the viewer' do
+ expect(viewer).to receive(:prepare!)
+
+ viewer.valid?
+ end
+
+ it 'processes dashboard yaml and returns true', :aggregate_failures do
+ yml = ::Gitlab::Config::Loader::Yaml.new(data).load_raw!
+
+ expect_next_instance_of(::Gitlab::Config::Loader::Yaml, data) do |loader|
+ expect(loader).to receive(:load_raw!).and_call_original
+ end
+ expect(Gitlab::Metrics::Dashboard::Validator)
+ .to receive(:errors)
+ .with(yml, dashboard_path: '.gitlab/dashboards/custom-dashboard.yml', project: project)
+ .and_call_original
+ expect(viewer.valid?).to be true
+ end
+ end
+
+ describe '#errors' do
+ it 'returns empty array' do
+ expect(viewer.errors).to eq []
+ end
+ end
+ end
+
+ context 'when definition is invalid' do
+ let(:error) { ::Gitlab::Metrics::Dashboard::Validator::Errors::SchemaValidationError.new }
+ let(:data) do
+ <<~YAML
+ dashboard:
+ YAML
+ end
+
+ before do
+ allow(Gitlab::Metrics::Dashboard::Validator).to receive(:errors).and_return([error])
+ end
- describe '#valid?' do
- it 'calls prepare! on the viewer' do
- allow(PerformanceMonitoring::PrometheusDashboard).to receive(:from_json)
+ describe '#valid?' do
+ it 'returns false' do
+ expect(viewer.valid?).to be false
+ end
+ end
- expect(viewer).to receive(:prepare!)
+ describe '#errors' do
+ it 'returns validation errors' do
+ expect(viewer.errors).to eq ["Dashboard failed schema validation"]
+ end
+ end
+ end
- viewer.valid?
+ context 'when YAML syntax is invalid' do
+ let(:data) do
+ <<~YAML
+ dashboard: 'empty metrics'
+ panel_groups:
+ - group: 'Group Title'
+ YAML
end
- it 'returns true', :aggregate_failures do
- yml = ::Gitlab::Config::Loader::Yaml.new(data).load_raw!
+ describe '#valid?' do
+ it 'returns false' do
+ expect(Gitlab::Metrics::Dashboard::Validator).not_to receive(:errors)
+ expect(viewer.valid?).to be false
+ end
+ end
- expect_next_instance_of(::Gitlab::Config::Loader::Yaml, data) do |loader|
- expect(loader).to receive(:load_raw!).and_call_original
+ describe '#errors' do
+ it 'returns validation errors' do
+ expect(viewer.errors).to all be_kind_of String
end
- expect(PerformanceMonitoring::PrometheusDashboard)
- .to receive(:from_json)
- .with(yml)
- .and_call_original
- expect(viewer.valid?).to be_truthy
end
end
- describe '#errors' do
- it 'returns nil' do
- allow(PerformanceMonitoring::PrometheusDashboard).to receive(:from_json)
+ context 'when YAML loader raises error' do
+ let(:data) do
+ <<~YAML
+ large yaml file
+ YAML
+ end
+
+ before do
+ allow(::Gitlab::Config::Loader::Yaml).to receive(:new)
+ .and_raise(::Gitlab::Config::Loader::Yaml::DataTooLargeError, 'The parsed YAML is too big')
+ end
- expect(viewer.errors).to be nil
+ it 'is invalid' do
+ expect(Gitlab::Metrics::Dashboard::Validator).not_to receive(:errors)
+ expect(viewer.valid?).to be false
+ end
+
+ it 'returns validation errors' do
+ expect(viewer.errors).to eq ['The parsed YAML is too big']
end
end
end
- context 'when definition is invalid' do
- let(:error) { ActiveModel::ValidationError.new(PerformanceMonitoring::PrometheusDashboard.new.tap(&:validate)) }
- let(:data) do
- <<~YAML
- dashboard:
- YAML
+ context 'with metrics_dashboard_exhaustive_validations feature flag off' do
+ before do
+ stub_feature_flags(metrics_dashboard_exhaustive_validations: false)
end
- describe '#valid?' do
- it 'returns false' do
- expect(PerformanceMonitoring::PrometheusDashboard)
- .to receive(:from_json).and_raise(error)
+ context 'when the definition is valid' do
+ describe '#valid?' do
+ before do
+ allow(PerformanceMonitoring::PrometheusDashboard).to receive(:from_json)
+ end
+
+ it 'calls prepare! on the viewer' do
+ expect(viewer).to receive(:prepare!)
- expect(viewer.valid?).to be_falsey
+ viewer.valid?
+ end
+
+ it 'processes dashboard yaml and returns true', :aggregate_failures do
+ yml = ::Gitlab::Config::Loader::Yaml.new(data).load_raw!
+
+ expect_next_instance_of(::Gitlab::Config::Loader::Yaml, data) do |loader|
+ expect(loader).to receive(:load_raw!).and_call_original
+ end
+ expect(PerformanceMonitoring::PrometheusDashboard)
+ .to receive(:from_json)
+ .with(yml)
+ .and_call_original
+ expect(viewer.valid?).to be true
+ end
+ end
+
+ describe '#errors' do
+ it 'returns empty array' do
+ expect(viewer.errors).to eq []
+ end
end
end
- describe '#errors' do
- it 'returns validation errors' do
- allow(PerformanceMonitoring::PrometheusDashboard)
- .to receive(:from_json).and_raise(error)
+ context 'when definition is invalid' do
+ let(:error) { ActiveModel::ValidationError.new(PerformanceMonitoring::PrometheusDashboard.new.tap(&:validate)) }
+ let(:data) do
+ <<~YAML
+ dashboard:
+ YAML
+ end
+
+ describe '#valid?' do
+ it 'returns false' do
+ expect(PerformanceMonitoring::PrometheusDashboard)
+ .to receive(:from_json).and_raise(error)
- expect(viewer.errors).to be error.model.errors
+ expect(viewer.valid?).to be false
+ end
+ end
+
+ describe '#errors' do
+ it 'returns validation errors' do
+ allow(PerformanceMonitoring::PrometheusDashboard)
+ .to receive(:from_json).and_raise(error)
+
+ expect(viewer.errors).to eq error.model.errors.messages.map { |messages| messages.join(': ') }
+ end
end
end
- end
- context 'when YAML syntax is invalid' do
- let(:data) do
- <<~YAML
+ context 'when YAML syntax is invalid' do
+ let(:data) do
+ <<~YAML
dashboard: 'empty metrics'
panel_groups:
- group: 'Group Title'
- YAML
- end
-
- describe '#valid?' do
- it 'returns false' do
- expect(PerformanceMonitoring::PrometheusDashboard).not_to receive(:from_json)
- expect(viewer.valid?).to be_falsey
+ YAML
end
- end
- describe '#errors' do
- it 'returns validation errors' do
- yaml_wrapped_errors = { 'YAML syntax': ["(<unknown>): did not find expected key while parsing a block mapping at line 1 column 1"] }
+ describe '#valid?' do
+ it 'returns false' do
+ expect(PerformanceMonitoring::PrometheusDashboard).not_to receive(:from_json)
+ expect(viewer.valid?).to be false
+ end
+ end
- expect(viewer.errors).to be_kind_of ActiveModel::Errors
- expect(viewer.errors.messages).to eql(yaml_wrapped_errors)
+ describe '#errors' do
+ it 'returns validation errors' do
+ expect(viewer.errors).to eq ["YAML syntax: (<unknown>): did not find expected key while parsing a block mapping at line 1 column 1"]
+ end
end
end
- end
- context 'when YAML loader raises error' do
- let(:data) do
- <<~YAML
+ context 'when YAML loader raises error' do
+ let(:data) do
+ <<~YAML
large yaml file
- YAML
- end
-
- before do
- allow(::Gitlab::Config::Loader::Yaml).to receive(:new)
- .and_raise(::Gitlab::Config::Loader::Yaml::DataTooLargeError, 'The parsed YAML is too big')
- end
+ YAML
+ end
- it 'is invalid' do
- expect(PerformanceMonitoring::PrometheusDashboard).not_to receive(:from_json)
- expect(viewer.valid?).to be(false)
- end
+ before do
+ allow(::Gitlab::Config::Loader::Yaml).to(
+ receive(:new).and_raise(::Gitlab::Config::Loader::Yaml::DataTooLargeError, 'The parsed YAML is too big')
+ )
+ end
- it 'returns validation errors' do
- yaml_wrapped_errors = { 'YAML syntax': ["The parsed YAML is too big"] }
+ it 'is invalid' do
+ expect(PerformanceMonitoring::PrometheusDashboard).not_to receive(:from_json)
+ expect(viewer.valid?).to be false
+ end
- expect(viewer.errors).to be_kind_of(ActiveModel::Errors)
- expect(viewer.errors.messages).to eq(yaml_wrapped_errors)
+ it 'returns validation errors' do
+ expect(viewer.errors).to eq ["YAML syntax: The parsed YAML is too big"]
+ end
end
end
end
diff --git a/spec/models/board_group_recent_visit_spec.rb b/spec/models/board_group_recent_visit_spec.rb
index 4d16e1ff839..c6fbd263072 100644
--- a/spec/models/board_group_recent_visit_spec.rb
+++ b/spec/models/board_group_recent_visit_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe BoardGroupRecentVisit do
let!(:visit) { create :board_group_recent_visit, group: board.group, board: board, user: user, updated_at: 7.days.ago }
it 'updates the timestamp' do
- Timecop.freeze do
+ freeze_time do
described_class.visited!(user, board)
expect(described_class.count).to eq 1
diff --git a/spec/models/board_project_recent_visit_spec.rb b/spec/models/board_project_recent_visit_spec.rb
index 8e74405fd8c..145a4f5b1a7 100644
--- a/spec/models/board_project_recent_visit_spec.rb
+++ b/spec/models/board_project_recent_visit_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe BoardProjectRecentVisit do
let!(:visit) { create :board_project_recent_visit, project: board.project, board: board, user: user, updated_at: 7.days.ago }
it 'updates the timestamp' do
- Timecop.freeze do
+ freeze_time do
described_class.visited!(user, board)
expect(described_class.count).to eq 1
diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb
index 3a459e5897a..850fc1ec6e6 100644
--- a/spec/models/ci/bridge_spec.rb
+++ b/spec/models/ci/bridge_spec.rb
@@ -50,6 +50,7 @@ RSpec.describe Ci::Bridge do
CI_PROJECT_PATH_SLUG CI_PROJECT_NAMESPACE CI_PROJECT_ROOT_NAMESPACE
CI_PIPELINE_IID CI_CONFIG_PATH CI_PIPELINE_SOURCE CI_COMMIT_MESSAGE
CI_COMMIT_TITLE CI_COMMIT_DESCRIPTION CI_COMMIT_REF_PROTECTED
+ CI_COMMIT_TIMESTAMP
]
expect(bridge.scoped_variables_hash.keys).to include(*variables)
diff --git a/spec/models/ci/build_metadata_spec.rb b/spec/models/ci/build_metadata_spec.rb
index e4d71632957..069864fa765 100644
--- a/spec/models/ci/build_metadata_spec.rb
+++ b/spec/models/ci/build_metadata_spec.rb
@@ -73,7 +73,7 @@ RSpec.describe Ci::BuildMetadata do
context 'when both runner and job timeouts are set' do
before do
- build.update(runner: runner)
+ build.update!(runner: runner)
end
context 'when job timeout is higher than runner timeout' do
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 069ac23c5a4..1e551d9ee33 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -25,6 +25,7 @@ RSpec.describe Ci::Build do
it { is_expected.to have_many(:sourced_pipelines) }
it { is_expected.to have_many(:job_variables) }
it { is_expected.to have_many(:report_results) }
+ it { is_expected.to have_many(:pages_deployments) }
it { is_expected.to have_one(:deployment) }
it { is_expected.to have_one(:runner_session) }
@@ -448,7 +449,7 @@ RSpec.describe Ci::Build do
end
it 'schedules BuildScheduleWorker at the right time' do
- Timecop.freeze do
+ freeze_time do
expect(Ci::BuildScheduleWorker)
.to receive(:perform_at).with(be_like_time(1.minute.since), build.id)
@@ -496,7 +497,7 @@ RSpec.describe Ci::Build do
let(:option) { { start_in: '1 day' } }
it 'returns date after 1 day' do
- Timecop.freeze do
+ freeze_time do
is_expected.to eq(1.day.since)
end
end
@@ -506,7 +507,7 @@ RSpec.describe Ci::Build do
let(:option) { { start_in: '1 week' } }
it 'returns date after 1 week' do
- Timecop.freeze do
+ freeze_time do
is_expected.to eq(1.week.since)
end
end
@@ -566,18 +567,18 @@ RSpec.describe Ci::Build do
let(:runner) { create(:ci_runner, :project, projects: [build.project]) }
before do
- runner.update(contacted_at: 1.second.ago)
+ runner.update!(contacted_at: 1.second.ago)
end
it { is_expected.to be_truthy }
it 'that is inactive' do
- runner.update(active: false)
+ runner.update!(active: false)
is_expected.to be_falsey
end
it 'that is not online' do
- runner.update(contacted_at: nil)
+ runner.update!(contacted_at: nil)
is_expected.to be_falsey
end
@@ -612,6 +613,46 @@ RSpec.describe Ci::Build do
end
end
+ describe '#locked_artifacts?' do
+ subject(:locked_artifacts) { build.locked_artifacts? }
+
+ context 'when pipeline is artifacts_locked' do
+ before do
+ build.pipeline.artifacts_locked!
+ end
+
+ context 'artifacts archive does not exist' do
+ let(:build) { create(:ci_build) }
+
+ it { is_expected.to be_falsy }
+ end
+
+ context 'artifacts archive exists' do
+ let(:build) { create(:ci_build, :artifacts) }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ context 'when pipeline is unlocked' do
+ before do
+ build.pipeline.unlocked!
+ end
+
+ context 'artifacts archive does not exist' do
+ let(:build) { create(:ci_build) }
+
+ it { is_expected.to be_falsy }
+ end
+
+ context 'artifacts archive exists' do
+ let(:build) { create(:ci_build, :artifacts) }
+
+ it { is_expected.to be_falsy }
+ end
+ end
+ end
+
describe '#available_artifacts?' do
let(:build) { create(:ci_build) }
@@ -683,7 +724,7 @@ RSpec.describe Ci::Build do
context 'is expired' do
before do
- build.update(artifacts_expire_at: Time.current - 7.days)
+ build.update!(artifacts_expire_at: Time.current - 7.days)
end
it { is_expected.to be_truthy }
@@ -691,7 +732,7 @@ RSpec.describe Ci::Build do
context 'is not expired' do
before do
- build.update(artifacts_expire_at: Time.current + 7.days)
+ build.update!(artifacts_expire_at: Time.current + 7.days)
end
it { is_expected.to be_falsey }
@@ -1012,18 +1053,53 @@ RSpec.describe Ci::Build do
end
describe '#hide_secrets' do
+ let(:metrics) { spy('metrics') }
let(:subject) { build.hide_secrets(data) }
context 'hide runners token' do
let(:data) { "new #{project.runners_token} data"}
it { is_expected.to match(/^new x+ data$/) }
+
+ it 'increments trace mutation metric' do
+ build.hide_secrets(data, metrics)
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :mutated)
+ end
end
context 'hide build token' do
let(:data) { "new #{build.token} data"}
it { is_expected.to match(/^new x+ data$/) }
+
+ it 'increments trace mutation metric' do
+ build.hide_secrets(data, metrics)
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :mutated)
+ end
+ end
+
+ context 'when build does not include secrets' do
+ let(:data) { 'my build log' }
+
+ it 'does not mutate trace' do
+ trace = build.hide_secrets(data)
+
+ expect(trace).to eq data
+ end
+
+ it 'does not increment trace mutation metric' do
+ build.hide_secrets(data, metrics)
+
+ expect(metrics)
+ .not_to have_received(:increment_trace_operation)
+ .with(operation: :mutated)
+ end
end
end
@@ -1200,7 +1276,7 @@ RSpec.describe Ci::Build do
context 'when environment is defined' do
before do
- build.update(environment: 'review')
+ build.update!(environment: 'review')
end
it { is_expected.to be_truthy }
@@ -1208,7 +1284,7 @@ RSpec.describe Ci::Build do
context 'when environment is not defined' do
before do
- build.update(environment: nil)
+ build.update!(environment: nil)
end
it { is_expected.to be_falsey }
@@ -1316,7 +1392,7 @@ RSpec.describe Ci::Build do
context 'when environment is defined' do
before do
- build.update(environment: 'review')
+ build.update!(environment: 'review')
end
context 'no action is defined' do
@@ -1325,7 +1401,7 @@ RSpec.describe Ci::Build do
context 'and start action is defined' do
before do
- build.update(options: { environment: { action: 'start' } } )
+ build.update!(options: { environment: { action: 'start' } } )
end
it { is_expected.to be_truthy }
@@ -1334,7 +1410,7 @@ RSpec.describe Ci::Build do
context 'when environment is not defined' do
before do
- build.update(environment: nil)
+ build.update!(environment: nil)
end
it { is_expected.to be_falsey }
@@ -1346,7 +1422,7 @@ RSpec.describe Ci::Build do
context 'when environment is defined' do
before do
- build.update(environment: 'review')
+ build.update!(environment: 'review')
end
context 'no action is defined' do
@@ -1355,7 +1431,7 @@ RSpec.describe Ci::Build do
context 'and stop action is defined' do
before do
- build.update(options: { environment: { action: 'stop' } } )
+ build.update!(options: { environment: { action: 'stop' } } )
end
it { is_expected.to be_truthy }
@@ -1364,7 +1440,7 @@ RSpec.describe Ci::Build do
context 'when environment is not defined' do
before do
- build.update(environment: nil)
+ build.update!(environment: nil)
end
it { is_expected.to be_falsey }
@@ -1687,7 +1763,7 @@ RSpec.describe Ci::Build do
describe '#action?' do
before do
- build.update(when: value)
+ build.update!(when: value)
end
subject { build.action? }
@@ -2232,7 +2308,7 @@ RSpec.describe Ci::Build do
describe '#has_expiring_archive_artifacts?' do
context 'when artifacts have expiration date set' do
before do
- build.update(artifacts_expire_at: 1.day.from_now)
+ build.update!(artifacts_expire_at: 1.day.from_now)
end
context 'and job artifacts archive record exists' do
@@ -2252,7 +2328,7 @@ RSpec.describe Ci::Build do
context 'when artifacts do not have expiration date set' do
before do
- build.update(artifacts_expire_at: nil)
+ build.update!(artifacts_expire_at: nil)
end
it 'does not have expiring artifacts' do
@@ -2329,6 +2405,7 @@ RSpec.describe Ci::Build do
{ 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_BUILD_REF', value: build.sha, public: true, masked: false },
{ key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true, masked: false },
{ key: 'CI_BUILD_REF_NAME', value: build.ref, public: true, masked: false },
@@ -2526,7 +2603,7 @@ RSpec.describe Ci::Build do
end
before do
- build.update(user: user)
+ build.update!(user: user)
end
it { user_variables.each { |v| is_expected.to include(v) } }
@@ -2562,7 +2639,7 @@ RSpec.describe Ci::Build do
end
before do
- build.update(environment: 'production')
+ build.update!(environment: 'production')
end
shared_examples 'containing environment variables' do
@@ -2589,7 +2666,7 @@ RSpec.describe Ci::Build do
context 'when the URL was set from the job' do
before do
- build.update(options: { environment: { url: url } })
+ build.update!(options: { environment: { url: url } })
end
it_behaves_like 'containing environment variables'
@@ -2607,7 +2684,7 @@ RSpec.describe Ci::Build do
context 'when the URL was not set from the job, but environment' do
before do
- environment.update(external_url: url)
+ environment.update!(external_url: url)
end
it_behaves_like 'containing environment variables'
@@ -2643,7 +2720,7 @@ RSpec.describe Ci::Build do
context 'when build started manually' do
before do
- build.update(when: :manual)
+ build.update!(when: :manual)
end
let(:manual_variable) do
@@ -2669,8 +2746,8 @@ RSpec.describe Ci::Build do
end
before do
- build.update(tag: false)
- pipeline.update(tag: false)
+ build.update!(tag: false)
+ pipeline.update!(tag: false)
end
it { is_expected.to include(branch_variable) }
@@ -2682,8 +2759,8 @@ RSpec.describe Ci::Build do
end
before do
- build.update(tag: true)
- pipeline.update(tag: true)
+ build.update!(tag: true)
+ pipeline.update!(tag: true)
end
it { is_expected.to include(tag_variable) }
@@ -2860,7 +2937,7 @@ RSpec.describe Ci::Build do
context 'and is disabled for project' do
before do
- project.update(container_registry_enabled: false)
+ project.update!(container_registry_enabled: false)
end
it { is_expected.to include(ci_registry) }
@@ -2869,7 +2946,7 @@ RSpec.describe Ci::Build do
context 'and is enabled for project' do
before do
- project.update(container_registry_enabled: true)
+ project.update!(container_registry_enabled: true)
end
it { is_expected.to include(ci_registry) }
@@ -2881,7 +2958,7 @@ RSpec.describe Ci::Build do
let(:runner) { create(:ci_runner, description: 'description', tag_list: %w(docker linux)) }
before do
- build.update(runner: runner)
+ build.update!(runner: runner)
end
it { is_expected.to include({ key: 'CI_RUNNER_ID', value: runner.id.to_s, public: true, masked: false }) }
@@ -3685,7 +3762,7 @@ RSpec.describe Ci::Build do
subject { described_class.where(id: build).matches_tag_ids(tag_ids) }
before do
- build.update(tag_list: build_tag_list)
+ build.update!(tag_list: build_tag_list)
end
context 'when have different tags' do
@@ -3731,7 +3808,7 @@ RSpec.describe Ci::Build do
subject { described_class.where(id: build).with_any_tags }
before do
- build.update(tag_list: tag_list)
+ build.update!(tag_list: tag_list)
end
context 'when does have tags' do
@@ -4087,7 +4164,7 @@ RSpec.describe Ci::Build do
let(:path) { 'other_artifacts_0.1.2/another-subdirectory/banana_sample.gif' }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
before do
diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb
index a6362d46449..fefe5e3bfca 100644
--- a/spec/models/ci/build_trace_chunk_spec.rb
+++ b/spec/models/ci/build_trace_chunk_spec.rb
@@ -21,6 +21,25 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
stub_artifacts_object_storage
end
+ describe 'chunk creation' do
+ let(:metrics) { spy('metrics') }
+
+ before do
+ allow(::Gitlab::Ci::Trace::Metrics)
+ .to receive(:new)
+ .and_return(metrics)
+ end
+
+ it 'increments trace operation chunked metric' do
+ build_trace_chunk.save!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :chunked)
+ .once
+ end
+ end
+
context 'FastDestroyAll' do
let(:parent) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: parent) }
@@ -32,7 +51,7 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
expect(external_data_counter).to be > 0
expect(subjects.count).to be > 0
- expect { subjects.first.destroy }.to raise_error('`destroy` and `destroy_all` are forbidden. Please use `fast_destroy_all`')
+ expect { subjects.first.destroy! }.to raise_error('`destroy` and `destroy_all` are forbidden. Please use `fast_destroy_all`')
expect { subjects.destroy_all }.to raise_error('`destroy` and `destroy_all` are forbidden. Please use `fast_destroy_all`') # rubocop: disable Cop/DestroyAll
expect(subjects.count).to be > 0
@@ -57,7 +76,7 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
expect(external_data_counter).to be > 0
expect(subjects.count).to be > 0
- expect { parent.destroy }.not_to raise_error
+ expect { parent.destroy! }.not_to raise_error
expect(subjects.count).to eq(0)
expect(external_data_counter).to eq(0)
@@ -222,6 +241,8 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
subject
build_trace_chunk.reload
+
+ expect(build_trace_chunk.checksum).to be_present
expect(build_trace_chunk.fog?).to be_truthy
expect(build_trace_chunk.data).to eq(new_data)
end
@@ -344,6 +365,24 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
it_behaves_like 'Scheduling no sidekiq worker'
end
end
+
+ describe 'append metrics' do
+ let(:metrics) { spy('metrics') }
+
+ before do
+ allow(::Gitlab::Ci::Trace::Metrics)
+ .to receive(:new)
+ .and_return(metrics)
+ end
+
+ it 'increments trace operation appended metric' do
+ build_trace_chunk.append('123456', 0)
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :appended)
+ end
+ end
end
describe '#truncate' do
@@ -461,6 +500,8 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
end
describe '#persist_data!' do
+ let(:build) { create(:ci_build, :running) }
+
subject { build_trace_chunk.persist_data! }
shared_examples_for 'Atomic operation' do
@@ -502,6 +543,12 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data)
end
+ it 'calculates CRC32 checksum' do
+ subject
+
+ expect(build_trace_chunk.reload.checksum).to eq '3398914352'
+ end
+
it_behaves_like 'Atomic operation'
end
@@ -516,6 +563,18 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil
expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to be_nil
end
+
+ context 'when chunk is a final one' do
+ before do
+ create(:ci_build_pending_state, build: build)
+ end
+
+ it 'persists the data' do
+ subject
+
+ expect(build_trace_chunk.fog?).to be_truthy
+ end
+ end
end
end
@@ -565,6 +624,18 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to eq(data)
expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to be_nil
end
+
+ context 'when chunk is a final one' do
+ before do
+ create(:ci_build_pending_state, build: build)
+ end
+
+ it 'persists the data' do
+ subject
+
+ expect(build_trace_chunk.fog?).to be_truthy
+ end
+ end
end
end
@@ -614,6 +685,54 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
end
end
+ describe 'final?' do
+ let(:build) { create(:ci_build, :running) }
+
+ context 'when build pending state exists' do
+ before do
+ create(:ci_build_pending_state, build: build)
+ end
+
+ context 'when chunks is not the last one' do
+ before do
+ create(:ci_build_trace_chunk, chunk_index: 1, build: build)
+ end
+
+ it 'is not a final chunk' do
+ expect(build.reload.pending_state).to be_present
+ expect(build_trace_chunk).not_to be_final
+ end
+ end
+
+ context 'when chunks is the last one' do
+ it 'is a final chunk' do
+ expect(build.reload.pending_state).to be_present
+ expect(build_trace_chunk).to be_final
+ end
+ end
+ end
+
+ context 'when build pending state does not exist' do
+ context 'when chunks is not the last one' do
+ before do
+ create(:ci_build_trace_chunk, chunk_index: 1, build: build)
+ end
+
+ it 'is not a final chunk' do
+ expect(build.reload.pending_state).not_to be_present
+ expect(build_trace_chunk).not_to be_final
+ end
+ end
+
+ context 'when chunks is the last one' do
+ it 'is not a final chunk' do
+ expect(build.reload.pending_state).not_to be_present
+ expect(build_trace_chunk).not_to be_final
+ end
+ end
+ end
+ end
+
describe 'deletes data in redis after a parent record destroyed' do
let(:project) { create(:project) }
diff --git a/spec/models/ci/build_trace_chunks/fog_spec.rb b/spec/models/ci/build_trace_chunks/fog_spec.rb
index a44ae58dfd2..b7e9adab04a 100644
--- a/spec/models/ci/build_trace_chunks/fog_spec.rb
+++ b/spec/models/ci/build_trace_chunks/fog_spec.rb
@@ -46,9 +46,7 @@ RSpec.describe Ci::BuildTraceChunks::Fog do
end
describe '#set_data' do
- subject { data_store.set_data(model, data) }
-
- let(:data) { 'abc123' }
+ let(:new_data) { 'abc123' }
context 'when data exists' do
let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: 'sample data in fog') }
@@ -56,9 +54,9 @@ RSpec.describe Ci::BuildTraceChunks::Fog do
it 'overwrites data' do
expect(data_store.data(model)).to eq('sample data in fog')
- subject
+ data_store.set_data(model, new_data)
- expect(data_store.data(model)).to eq('abc123')
+ expect(data_store.data(model)).to eq new_data
end
end
@@ -68,9 +66,9 @@ RSpec.describe Ci::BuildTraceChunks::Fog do
it 'sets new data' do
expect(data_store.data(model)).to be_nil
- subject
+ data_store.set_data(model, new_data)
- expect(data_store.data(model)).to eq('abc123')
+ expect(data_store.data(model)).to eq new_data
end
end
end
diff --git a/spec/models/ci/instance_variable_spec.rb b/spec/models/ci/instance_variable_spec.rb
index 15d0c911bc4..0ef1dfbd55c 100644
--- a/spec/models/ci/instance_variable_spec.rb
+++ b/spec/models/ci/instance_variable_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Ci::InstanceVariable do
let(:value) { SecureRandom.alphanumeric(10_002) }
it 'raises a database level error' do
- expect { variable.save }.to raise_error(ActiveRecord::StatementInvalid)
+ expect { variable.save! }.to raise_error(ActiveRecord::StatementInvalid)
end
end
@@ -36,7 +36,7 @@ RSpec.describe Ci::InstanceVariable do
let(:value) { SecureRandom.alphanumeric(10_000) }
it 'does not raise database level error' do
- expect { variable.save }.not_to raise_error
+ expect { variable.save! }.not_to raise_error
end
end
end
@@ -85,7 +85,7 @@ RSpec.describe Ci::InstanceVariable do
it 'resets the cache when records are deleted' do
expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable)
- protected_variable.destroy
+ protected_variable.destroy!
expect(described_class.all_cached).to contain_exactly(unprotected_variable)
end
diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb
index 91a669aa3f4..779839df670 100644
--- a/spec/models/ci/job_artifact_spec.rb
+++ b/spec/models/ci/job_artifact_spec.rb
@@ -20,7 +20,9 @@ RSpec.describe Ci::JobArtifact do
it_behaves_like 'having unique enum values'
it_behaves_like 'UpdateProjectStatistics' do
- subject { build(:ci_job_artifact, :archive, size: 107464) }
+ let_it_be(:job, reload: true) { create(:ci_build) }
+
+ subject { build(:ci_job_artifact, :archive, job: job, size: 107464) }
end
describe '.not_expired' do
@@ -343,42 +345,6 @@ RSpec.describe Ci::JobArtifact do
end
end
- describe '#each_blob' do
- context 'when file format is gzip' do
- context 'when gzip file contains one file' do
- let(:artifact) { build(:ci_job_artifact, :junit) }
-
- it 'iterates blob once' do
- expect { |b| artifact.each_blob(&b) }.to yield_control.once
- end
- end
-
- context 'when gzip file contains three files' do
- let(:artifact) { build(:ci_job_artifact, :junit_with_three_testsuites) }
-
- it 'iterates blob three times' do
- expect { |b| artifact.each_blob(&b) }.to yield_control.exactly(3).times
- end
- end
- end
-
- context 'when file format is raw' do
- let(:artifact) { build(:ci_job_artifact, :codequality, file_format: :raw) }
-
- it 'iterates blob once' do
- expect { |b| artifact.each_blob(&b) }.to yield_control.once
- end
- end
-
- context 'when there are no adapters for the file format' do
- let(:artifact) { build(:ci_job_artifact, :junit, file_format: :zip) }
-
- it 'raises an error' do
- expect { |b| artifact.each_blob(&b) }.to raise_error(described_class::NotSupportedAdapterError)
- end
- end
- end
-
describe 'expired?' do
subject { artifact.expired? }
diff --git a/spec/models/ci/legacy_stage_spec.rb b/spec/models/ci/legacy_stage_spec.rb
index c53f6abb037..2487ad85889 100644
--- a/spec/models/ci/legacy_stage_spec.rb
+++ b/spec/models/ci/legacy_stage_spec.rb
@@ -116,7 +116,7 @@ RSpec.describe Ci::LegacyStage do
let!(:new_build) { create_job(:ci_build, status: :success) }
before do
- stage_build.update(retried: true)
+ stage_build.update!(retried: true)
end
it "returns status of latest build" do
diff --git a/spec/models/ci/persistent_ref_spec.rb b/spec/models/ci/persistent_ref_spec.rb
index 18552317025..e488580ae7b 100644
--- a/spec/models/ci/persistent_ref_spec.rb
+++ b/spec/models/ci/persistent_ref_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe Ci::PersistentRef do
context 'when a persistent ref exists' do
before do
- pipeline.persistent_ref.create
+ pipeline.persistent_ref.create # rubocop: disable Rails/SaveBang
end
it { is_expected.to eq(true) }
@@ -32,7 +32,7 @@ RSpec.describe Ci::PersistentRef do
end
describe '#create' do
- subject { pipeline.persistent_ref.create }
+ subject { pipeline.persistent_ref.create } # rubocop: disable Rails/SaveBang
let(:pipeline) { create(:ci_pipeline, sha: sha, project: project) }
let(:project) { create(:project, :repository) }
@@ -58,7 +58,7 @@ RSpec.describe Ci::PersistentRef do
context 'when a persistent ref already exists' do
before do
- pipeline.persistent_ref.create
+ pipeline.persistent_ref.create # rubocop: disable Rails/SaveBang
end
it 'overwrites a persistent ref' do
@@ -78,7 +78,7 @@ RSpec.describe Ci::PersistentRef do
context 'when a persistent ref exists' do
before do
- pipeline.persistent_ref.create
+ pipeline.persistent_ref.create # rubocop: disable Rails/SaveBang
end
it 'deletes the ref' do
diff --git a/spec/models/ci/pipeline_artifact_spec.rb b/spec/models/ci/pipeline_artifact_spec.rb
index 9d63d74a6cc..8cbace845a9 100644
--- a/spec/models/ci/pipeline_artifact_spec.rb
+++ b/spec/models/ci/pipeline_artifact_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Ci::PipelineArtifact, type: :model do
- let_it_be(:coverage_report) { create(:ci_pipeline_artifact) }
+ let(:coverage_report) { create(:ci_pipeline_artifact) }
describe 'associations' do
it { is_expected.to belong_to(:pipeline) }
@@ -12,6 +12,12 @@ RSpec.describe Ci::PipelineArtifact, type: :model do
it_behaves_like 'having unique enum values'
+ it_behaves_like 'UpdateProjectStatistics' do
+ let_it_be(:pipeline, reload: true) { create(:ci_pipeline) }
+
+ subject { build(:ci_pipeline_artifact, pipeline: pipeline) }
+ end
+
describe 'validations' do
it { is_expected.to validate_presence_of(:pipeline) }
it { is_expected.to validate_presence_of(:project) }
@@ -44,38 +50,74 @@ RSpec.describe Ci::PipelineArtifact, type: :model do
end
end
- describe '#set_size' do
+ describe 'file is being stored' do
subject { create(:ci_pipeline_artifact) }
- context 'when file is being created' do
- it 'sets the size' do
- expect(subject.size).to eq(85)
+ context 'when existing object has local store' do
+ it_behaves_like 'mounted file in local store'
+ end
+
+ context 'when direct upload is enabled' do
+ before do
+ stub_artifacts_object_storage(Ci::PipelineArtifactUploader, direct_upload: true)
+ end
+
+ context 'when file is stored' do
+ it_behaves_like 'mounted file in object store'
end
end
- context 'when file is being updated' do
- it 'updates the size' do
- subject.update!(file: fixture_file_upload('spec/fixtures/dk.png'))
+ context 'when file contains multi-byte characters' do
+ let(:coverage_report_multibyte) { create(:ci_pipeline_artifact, :with_multibyte_characters) }
- expect(subject.size).to eq(1062)
+ it 'sets the size in bytesize' do
+ expect(coverage_report_multibyte.size).to eq(14)
end
end
end
- describe 'file is being stored' do
- subject { create(:ci_pipeline_artifact) }
+ describe '.has_code_coverage?' do
+ subject { Ci::PipelineArtifact.has_code_coverage? }
- context 'when existing object has local store' do
- it_behaves_like 'mounted file in local store'
+ context 'when pipeline artifact has a code coverage' do
+ let!(:pipeline_artifact) { create(:ci_pipeline_artifact) }
+
+ it 'returns true' do
+ expect(subject).to be_truthy
+ end
end
- context 'when direct upload is enabled' do
- before do
- stub_artifacts_object_storage(Ci::PipelineArtifactUploader, direct_upload: true)
+ context 'when pipeline artifact does not have a code coverage' do
+ it 'returns false' do
+ expect(subject).to be_falsey
end
+ end
+ end
- context 'when file is stored' do
- it_behaves_like 'mounted file in object store'
+ describe '.find_with_code_coverage' do
+ subject { Ci::PipelineArtifact.find_with_code_coverage }
+
+ context 'when pipeline artifact has a coverage report' do
+ let!(:coverage_report) { create(:ci_pipeline_artifact) }
+
+ it 'returns a pipeline artifact with a code coverage' do
+ expect(subject.file_type).to eq('code_coverage')
+ end
+ end
+
+ context 'when pipeline artifact does not have a coverage report' do
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+ end
+
+ describe '#present' do
+ subject { coverage_report.present }
+
+ context 'when file_type is code_coverage' do
+ it 'uses code coverage presenter' do
+ expect(subject.present).to be_kind_of(Ci::PipelineArtifacts::CodeCoveragePresenter)
end
end
end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index b4e80fa7588..228a1e8f7a2 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -2,12 +2,14 @@
require 'spec_helper'
-RSpec.describe Ci::Pipeline, :mailer do
+RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
include ProjectForksHelper
include StubRequests
+ include Ci::SourcePipelineHelpers
- let(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:namespace) { create_default(:namespace) }
+ let_it_be(:project) { create_default(:project, :repository) }
let(:pipeline) do
create(:ci_empty_pipeline, status: :created, project: project)
@@ -220,6 +222,26 @@ RSpec.describe Ci::Pipeline, :mailer do
end
end
+ describe '.ci_sources' do
+ subject { described_class.ci_sources }
+
+ let!(:push_pipeline) { create(:ci_pipeline, source: :push) }
+ let!(:web_pipeline) { create(:ci_pipeline, source: :web) }
+ let!(:api_pipeline) { create(:ci_pipeline, source: :api) }
+ let!(:webide_pipeline) { create(:ci_pipeline, source: :webide) }
+ let!(:child_pipeline) { create(:ci_pipeline, source: :parent_pipeline) }
+
+ it 'contains pipelines having CI only sources' do
+ expect(subject).to contain_exactly(push_pipeline, web_pipeline, api_pipeline)
+ end
+
+ it 'filters on expected sources' do
+ expect(::Enums::Ci::Pipeline.ci_sources.keys).to contain_exactly(
+ *%i[unknown push web trigger schedule api external pipeline chat
+ merge_request_event external_pull_request_event])
+ end
+ end
+
describe '.outside_pipeline_family' do
subject(:outside_pipeline_family) { described_class.outside_pipeline_family(upstream_pipeline) }
@@ -714,6 +736,7 @@ RSpec.describe Ci::Pipeline, :mailer do
CI_COMMIT_TITLE
CI_COMMIT_DESCRIPTION
CI_COMMIT_REF_PROTECTED
+ CI_COMMIT_TIMESTAMP
CI_BUILD_REF
CI_BUILD_BEFORE_SHA
CI_BUILD_REF_NAME
@@ -879,7 +902,7 @@ RSpec.describe Ci::Pipeline, :mailer do
context 'when there is auto_canceled_by' do
before do
- pipeline.update(auto_canceled_by: create(:ci_empty_pipeline))
+ pipeline.update!(auto_canceled_by: create(:ci_empty_pipeline))
end
it 'is auto canceled' do
@@ -1237,14 +1260,42 @@ RSpec.describe Ci::Pipeline, :mailer do
end
describe 'pipeline caching' do
- before do
- pipeline.config_source = 'repository_source'
+ context 'if pipeline is cacheable' do
+ before do
+ pipeline.source = 'push'
+ end
+
+ it 'performs ExpirePipelinesCacheWorker' do
+ expect(ExpirePipelineCacheWorker).to receive(:perform_async).with(pipeline.id)
+
+ pipeline.cancel
+ end
end
- it 'performs ExpirePipelinesCacheWorker' do
- expect(ExpirePipelineCacheWorker).to receive(:perform_async).with(pipeline.id)
+ context 'if pipeline is not cacheable' do
+ before do
+ pipeline.source = 'webide'
+ end
- pipeline.cancel
+ it 'deos not perform ExpirePipelinesCacheWorker' do
+ expect(ExpirePipelineCacheWorker).not_to receive(:perform_async)
+
+ pipeline.cancel
+ end
+ end
+ end
+
+ describe '#dangling?' do
+ it 'returns true if pipeline comes from any dangling sources' do
+ pipeline.source = Enums::Ci::Pipeline.dangling_sources.each_key.first
+
+ expect(pipeline).to be_dangling
+ end
+
+ it 'returns true if pipeline comes from any CI sources' do
+ pipeline.source = Enums::Ci::Pipeline.ci_sources.each_key.first
+
+ expect(pipeline).not_to be_dangling
end
end
@@ -1309,34 +1360,126 @@ RSpec.describe Ci::Pipeline, :mailer do
end
end
- context 'when pipeline is bridge triggered' do
- before do
- pipeline.source_bridge = create(:ci_bridge)
- end
+ def auto_devops_pipelines_completed_total(status)
+ Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines').get(status: status)
+ end
+ end
+
+ describe 'bridge triggered pipeline' do
+ shared_examples 'upstream downstream pipeline' do
+ let!(:source_pipeline) { create(:ci_sources_pipeline, pipeline: downstream_pipeline, source_job: bridge) }
+ let!(:job) { downstream_pipeline.builds.first }
context 'when source bridge is dependent on pipeline status' do
- before do
- allow(pipeline.source_bridge).to receive(:dependent?).and_return(true)
- end
+ let!(:bridge) { create(:ci_bridge, :strategy_depend, pipeline: upstream_pipeline) }
it 'schedules the pipeline bridge worker' do
- expect(::Ci::PipelineBridgeStatusWorker).to receive(:perform_async)
+ expect(::Ci::PipelineBridgeStatusWorker).to receive(:perform_async).with(downstream_pipeline.id)
+
+ downstream_pipeline.succeed!
+ end
+
+ context 'when the downstream pipeline first fails then retries and succeeds' do
+ it 'makes the upstream pipeline successful' do
+ Sidekiq::Testing.inline! { job.drop! }
+
+ expect(downstream_pipeline.reload).to be_failed
+ expect(upstream_pipeline.reload).to be_failed
+
+ Sidekiq::Testing.inline! do
+ new_job = Ci::Build.retry(job, project.users.first)
+
+ expect(downstream_pipeline.reload).to be_running
+ expect(upstream_pipeline.reload).to be_running
+
+ new_job.success!
+ end
+
+ expect(downstream_pipeline.reload).to be_success
+ expect(upstream_pipeline.reload).to be_success
+ end
+ end
+
+ context 'when the downstream pipeline first succeeds then retries and fails' do
+ it 'makes the upstream pipeline failed' do
+ Sidekiq::Testing.inline! { job.success! }
+
+ expect(downstream_pipeline.reload).to be_success
+ expect(upstream_pipeline.reload).to be_success
+
+ Sidekiq::Testing.inline! do
+ new_job = Ci::Build.retry(job, project.users.first)
+
+ expect(downstream_pipeline.reload).to be_running
+ expect(upstream_pipeline.reload).to be_running
+
+ new_job.drop!
+ end
+
+ expect(downstream_pipeline.reload).to be_failed
+ expect(upstream_pipeline.reload).to be_failed
+ end
+ end
+
+ context 'when the upstream pipeline has another dependent upstream pipeline' do
+ let!(:upstream_of_upstream_pipeline) { create(:ci_pipeline) }
+
+ 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)
+ end
+
+ context 'when the downstream pipeline first fails then retries and succeeds' do
+ it 'makes upstream pipelines successful' do
+ Sidekiq::Testing.inline! { job.drop! }
+
+ expect(downstream_pipeline.reload).to be_failed
+ expect(upstream_pipeline.reload).to be_failed
+ expect(upstream_of_upstream_pipeline.reload).to be_failed
- pipeline.succeed!
+ Sidekiq::Testing.inline! do
+ new_job = Ci::Build.retry(job, project.users.first)
+
+ expect(downstream_pipeline.reload).to be_running
+ expect(upstream_pipeline.reload).to be_running
+ expect(upstream_of_upstream_pipeline.reload).to be_running
+
+ new_job.success!
+ end
+
+ expect(downstream_pipeline.reload).to be_success
+ expect(upstream_pipeline.reload).to be_success
+ expect(upstream_of_upstream_pipeline.reload).to be_success
+ end
+ end
end
end
context 'when source bridge is not dependent on pipeline status' do
+ let!(:bridge) { create(:ci_bridge, pipeline: upstream_pipeline) }
+
it 'does not schedule the pipeline bridge worker' do
expect(::Ci::PipelineBridgeStatusWorker).not_to receive(:perform_async)
- pipeline.succeed!
+ downstream_pipeline.succeed!
end
end
end
- def auto_devops_pipelines_completed_total(status)
- Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines').get(status: status)
+ context 'multi-project pipelines' do
+ let!(:downstream_project) { create(:project, :repository) }
+ let!(:upstream_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:downstream_pipeline) { create(:ci_pipeline, :with_job, project: downstream_project) }
+
+ it_behaves_like 'upstream downstream pipeline'
+ end
+
+ context 'parent-child pipelines' do
+ let!(:upstream_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:downstream_pipeline) { create(:ci_pipeline, :with_job, project: project) }
+
+ it_behaves_like 'upstream downstream pipeline'
end
end
@@ -1436,8 +1579,6 @@ RSpec.describe Ci::Pipeline, :mailer do
context 'when repository exists' do
using RSpec::Parameterized::TableSyntax
- let(:project) { create(:project, :repository) }
-
where(:tag, :ref, :result) do
false | 'master' | true
false | 'non-existent-branch' | false
@@ -1457,6 +1598,7 @@ RSpec.describe Ci::Pipeline, :mailer do
end
context 'when repository does not exist' do
+ let(:project) { create(:project) }
let(:pipeline) do
create(:ci_empty_pipeline, project: project, ref: 'master')
end
@@ -1468,8 +1610,6 @@ RSpec.describe Ci::Pipeline, :mailer do
end
context 'with non-empty project' do
- let(:project) { create(:project, :repository) }
-
let(:pipeline) do
create(:ci_pipeline,
project: project,
@@ -1524,7 +1664,7 @@ RSpec.describe Ci::Pipeline, :mailer do
it 'looks up a commit for a tag' do
expect(project.repository.branch_names).not_to include 'v1.0.0'
- pipeline.update(sha: project.commit('v1.0.0').sha, ref: 'v1.0.0', tag: true)
+ pipeline.update!(sha: project.commit('v1.0.0').sha, ref: 'v1.0.0', tag: true)
expect(pipeline).to be_tag
expect(pipeline).to be_latest
@@ -1533,7 +1673,7 @@ RSpec.describe Ci::Pipeline, :mailer do
context 'with not latest sha' do
before do
- pipeline.update(sha: project.commit("#{project.default_branch}~1").sha)
+ pipeline.update!(sha: project.commit("#{project.default_branch}~1").sha)
end
it 'returns false' do
@@ -1561,7 +1701,7 @@ RSpec.describe Ci::Pipeline, :mailer do
let!(:manual2) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy') }
before do
- manual.update(retried: true)
+ manual.update!(retried: true)
end
it 'returns latest one' do
@@ -1596,10 +1736,8 @@ RSpec.describe Ci::Pipeline, :mailer do
describe '#modified_paths' do
context 'when old and new revisions are set' do
- let(:project) { create(:project, :repository) }
-
before do
- pipeline.update(before_sha: '1234abcd', sha: '2345bcde')
+ pipeline.update!(before_sha: '1234abcd', sha: '2345bcde')
end
it 'fetches stats for changes between commits' do
@@ -1866,8 +2004,6 @@ RSpec.describe Ci::Pipeline, :mailer do
end
describe '.latest_pipeline_per_commit' do
- let(:project) { create(:project) }
-
let!(:commit_123_ref_master) do
create(
:ci_empty_pipeline,
@@ -1962,15 +2098,14 @@ RSpec.describe Ci::Pipeline, :mailer do
end
describe '.last_finished_for_ref_id' do
- let(:project) { create(:project, :repository) }
let(:branch) { project.default_branch }
let(:ref) { project.ci_refs.take }
- let(:config_source) { Ci::PipelineEnums.config_sources[:parameter_source] }
+ let(:dangling_source) { Enums::Ci::Pipeline.sources[:ondemand_dast_scan] }
let!(:pipeline1) { create(:ci_pipeline, :success, project: project, ref: branch) }
let!(:pipeline2) { create(:ci_pipeline, :success, project: project, ref: branch) }
let!(:pipeline3) { create(:ci_pipeline, :failed, project: project, ref: branch) }
let!(:pipeline4) { create(:ci_pipeline, :success, project: project, ref: branch) }
- let!(:pipeline5) { create(:ci_pipeline, :success, project: project, ref: branch, config_source: config_source) }
+ let!(:pipeline5) { create(:ci_pipeline, :success, project: project, ref: branch, source: dangling_source) }
it 'returns the expected pipeline' do
result = described_class.last_finished_for_ref_id(ref.id)
@@ -2452,7 +2587,6 @@ RSpec.describe Ci::Pipeline, :mailer do
end
describe "#merge_requests_as_head_pipeline" do
- let(:project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master', sha: 'a288a022a53a5a944fae87bcec6efc87b7061808') }
it "returns merge requests whose `diff_head_sha` matches the pipeline's SHA" do
@@ -2575,11 +2709,11 @@ RSpec.describe Ci::Pipeline, :mailer do
end
describe '#same_family_pipeline_ids' do
- subject(:same_family_pipeline_ids) { pipeline.same_family_pipeline_ids }
+ subject { pipeline.same_family_pipeline_ids.map(&:id) }
context 'when pipeline is not child nor parent' do
it 'returns just the pipeline id' do
- expect(same_family_pipeline_ids).to contain_exactly(pipeline.id)
+ expect(subject).to contain_exactly(pipeline.id)
end
end
@@ -2588,21 +2722,12 @@ RSpec.describe Ci::Pipeline, :mailer do
let(:sibling) { create(:ci_pipeline, project: pipeline.project) }
before do
- create(:ci_sources_pipeline,
- source_job: create(:ci_build, pipeline: parent),
- source_project: parent.project,
- pipeline: pipeline,
- project: pipeline.project)
-
- create(:ci_sources_pipeline,
- source_job: create(:ci_build, pipeline: parent),
- source_project: parent.project,
- pipeline: sibling,
- project: sibling.project)
+ create_source_pipeline(parent, pipeline)
+ create_source_pipeline(parent, sibling)
end
it 'returns parent sibling and self ids' do
- expect(same_family_pipeline_ids).to contain_exactly(parent.id, pipeline.id, sibling.id)
+ expect(subject).to contain_exactly(parent.id, pipeline.id, sibling.id)
end
end
@@ -2610,15 +2735,43 @@ RSpec.describe Ci::Pipeline, :mailer do
let(:child) { create(:ci_pipeline, project: pipeline.project) }
before do
- create(:ci_sources_pipeline,
- source_job: create(:ci_build, pipeline: pipeline),
- source_project: pipeline.project,
- pipeline: child,
- project: child.project)
+ create_source_pipeline(pipeline, child)
end
it 'returns self and child ids' do
- expect(same_family_pipeline_ids).to contain_exactly(pipeline.id, child.id)
+ expect(subject).to contain_exactly(pipeline.id, child.id)
+ end
+ end
+
+ context 'when pipeline is a child of a child pipeline' do
+ let(:ancestor) { create(:ci_pipeline, project: pipeline.project) }
+ let(:parent) { create(:ci_pipeline, project: pipeline.project) }
+ let(:cousin_parent) { create(:ci_pipeline, project: pipeline.project) }
+ let(:cousin) { create(:ci_pipeline, project: pipeline.project) }
+
+ before do
+ create_source_pipeline(ancestor, parent)
+ create_source_pipeline(ancestor, cousin_parent)
+ create_source_pipeline(parent, pipeline)
+ create_source_pipeline(cousin_parent, cousin)
+ end
+
+ it 'returns all family ids' do
+ expect(subject).to contain_exactly(
+ ancestor.id, parent.id, cousin_parent.id, cousin.id, pipeline.id
+ )
+ end
+ end
+
+ context 'when pipeline is a triggered pipeline' do
+ let(:upstream) { create(:ci_pipeline, project: create(:project)) }
+
+ before do
+ create_source_pipeline(upstream, pipeline)
+ end
+
+ it 'returns self id' do
+ expect(subject).to contain_exactly(pipeline.id)
end
end
end
@@ -2685,7 +2838,8 @@ RSpec.describe Ci::Pipeline, :mailer do
end
describe 'notifications when pipeline success or failed' do
- let(:project) { create(:project, :repository) }
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, :repository, namespace: namespace) }
let(:pipeline) do
create(:ci_pipeline,
@@ -2698,7 +2852,7 @@ RSpec.describe Ci::Pipeline, :mailer do
project.add_developer(pipeline.user)
pipeline.user.global_notification_setting
- .update(level: 'custom', failed_pipeline: true, success_pipeline: true)
+ .update!(level: 'custom', failed_pipeline: true, success_pipeline: true)
perform_enqueued_jobs do
pipeline.enqueue
@@ -2948,6 +3102,54 @@ RSpec.describe Ci::Pipeline, :mailer do
end
end
+ describe '#has_coverage_reports?' do
+ subject { pipeline.has_coverage_reports? }
+
+ context 'when pipeline has a code coverage artifact' do
+ let(:pipeline) { create(:ci_pipeline, :with_coverage_report_artifact, :running, project: project) }
+
+ it { expect(subject).to be_truthy }
+ end
+
+ context 'when pipeline does not have a code coverage artifact' do
+ let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+
+ it { expect(subject).to be_falsey }
+ end
+ end
+
+ describe '#can_generate_coverage_reports?' do
+ subject { pipeline.can_generate_coverage_reports? }
+
+ context 'when pipeline has builds with coverage reports' do
+ before do
+ create(:ci_build, :coverage_reports, pipeline: pipeline, project: project)
+ end
+
+ context 'when pipeline status is running' do
+ let(:pipeline) { create(:ci_pipeline, :running, project: project) }
+
+ it { expect(subject).to be_falsey }
+ end
+
+ context 'when pipeline status is success' do
+ let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+
+ it { expect(subject).to be_truthy }
+ end
+ end
+
+ context 'when pipeline does not have builds with coverage reports' do
+ before do
+ create(:ci_build, :artifacts, pipeline: pipeline, project: project)
+ end
+
+ let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+
+ it { expect(subject).to be_falsey }
+ end
+ end
+
describe '#test_report_summary' do
subject { pipeline.test_report_summary }
@@ -3228,7 +3430,8 @@ RSpec.describe Ci::Pipeline, :mailer do
end
describe '#parent_pipeline' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
+
let(:pipeline) { create(:ci_pipeline, project: project) }
context 'when pipeline is triggered by a pipeline from the same project' do
@@ -3283,7 +3486,7 @@ RSpec.describe Ci::Pipeline, :mailer do
end
describe '#child_pipelines' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
context 'when pipeline triggered other pipelines on same project' do
@@ -3413,4 +3616,152 @@ RSpec.describe Ci::Pipeline, :mailer do
it { is_expected.to eq(Gitlab::Git::TAG_REF_PREFIX + pipeline.source_ref.to_s) }
end
end
+
+ describe "#builds_with_coverage" do
+ it 'returns builds with coverage only' do
+ rspec = create(:ci_build, name: 'rspec', coverage: 97.1, pipeline: pipeline)
+ jest = create(:ci_build, name: 'jest', coverage: 94.1, pipeline: pipeline)
+ karma = create(:ci_build, name: 'karma', coverage: nil, pipeline: pipeline)
+
+ builds = pipeline.builds_with_coverage
+
+ expect(builds).to include(rspec, jest)
+ expect(builds).not_to include(karma)
+ end
+ end
+
+ describe '#base_and_ancestors' do
+ let(:same_project) { false }
+
+ subject { pipeline.base_and_ancestors(same_project: same_project) }
+
+ context 'when pipeline is not child nor parent' do
+ it 'returns just the pipeline itself' do
+ expect(subject).to contain_exactly(pipeline)
+ end
+ end
+
+ context 'when pipeline is child' do
+ let(:parent) { create(:ci_pipeline, project: pipeline.project) }
+ let(:sibling) { create(:ci_pipeline, project: pipeline.project) }
+
+ before do
+ create_source_pipeline(parent, pipeline)
+ create_source_pipeline(parent, sibling)
+ end
+
+ it 'returns parent and self' do
+ expect(subject).to contain_exactly(parent, pipeline)
+ end
+ end
+
+ context 'when pipeline is parent' do
+ let(:child) { create(:ci_pipeline, project: pipeline.project) }
+
+ before do
+ create_source_pipeline(pipeline, child)
+ end
+
+ it 'returns self' do
+ expect(subject).to contain_exactly(pipeline)
+ end
+ end
+
+ context 'when pipeline is a child of a child pipeline' do
+ let(:ancestor) { create(:ci_pipeline, project: pipeline.project) }
+ let(:parent) { create(:ci_pipeline, project: pipeline.project) }
+
+ before do
+ create_source_pipeline(ancestor, parent)
+ create_source_pipeline(parent, pipeline)
+ end
+
+ it 'returns self, parent and ancestor' do
+ expect(subject).to contain_exactly(ancestor, parent, pipeline)
+ end
+ end
+
+ context 'when pipeline is a triggered pipeline' do
+ let(:upstream) { create(:ci_pipeline, project: create(:project)) }
+
+ before do
+ create_source_pipeline(upstream, pipeline)
+ end
+
+ context 'same_project: false' do
+ it 'returns upstream and self' do
+ expect(subject).to contain_exactly(pipeline, upstream)
+ end
+ end
+
+ context 'same_project: true' do
+ let(:same_project) { true }
+
+ it 'returns self' do
+ expect(subject).to contain_exactly(pipeline)
+ end
+ end
+ end
+ end
+
+ describe 'reset_ancestor_bridges!' do
+ context 'when the pipeline is a child pipeline and the bridge is depended' do
+ let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:bridge) { create_bridge(parent_pipeline, pipeline, true) }
+
+ it 'marks source bridge as pending' do
+ pipeline.reset_ancestor_bridges!
+
+ expect(bridge.reload).to be_pending
+ end
+
+ context 'when the parent pipeline has a dependent upstream pipeline' do
+ let!(:upstream_bridge) do
+ create_bridge(create(:ci_pipeline, project: create(:project)), parent_pipeline, true)
+ end
+
+ it 'marks all source bridges as pending' do
+ pipeline.reset_ancestor_bridges!
+
+ expect(bridge.reload).to be_pending
+ expect(upstream_bridge.reload).to be_pending
+ end
+ end
+ end
+
+ context 'when the pipeline is a child pipeline and the bridge is not depended' do
+ let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:bridge) { create_bridge(parent_pipeline, pipeline, false) }
+
+ it 'does not touch source bridge' do
+ pipeline.reset_ancestor_bridges!
+
+ expect(bridge.reload).to be_success
+ end
+
+ context 'when the parent pipeline has a dependent upstream pipeline' do
+ let!(:upstream_bridge) do
+ create_bridge(create(:ci_pipeline, project: create(:project)), parent_pipeline, true)
+ end
+
+ it 'does not touch any source bridge' do
+ pipeline.reset_ancestor_bridges!
+
+ expect(bridge.reload).to be_success
+ expect(upstream_bridge.reload).to be_success
+ end
+ end
+ end
+
+ private
+
+ def create_bridge(upstream, downstream, depend = false)
+ options = depend ? { trigger: { strategy: 'depend' } } : {}
+
+ bridge = create(:ci_bridge, pipeline: upstream, status: 'success', options: options)
+ create(:ci_sources_pipeline, pipeline: downstream, source_job: bridge)
+
+ bridge
+ end
+ end
end
diff --git a/spec/models/ci/ref_spec.rb b/spec/models/ci/ref_spec.rb
index 8bce3c10d8c..cb62646532c 100644
--- a/spec/models/ci/ref_spec.rb
+++ b/spec/models/ci/ref_spec.rb
@@ -16,51 +16,33 @@ RSpec.describe Ci::Ref do
stub_const('Ci::PipelineSuccessUnlockArtifactsWorker', unlock_artifacts_worker_spy)
end
- context 'when keep latest artifact feature is enabled' do
- before do
- stub_feature_flags(keep_latest_artifacts_for_ref: true)
- end
-
- where(:initial_state, :action, :count) do
- :unknown | :succeed! | 1
- :unknown | :do_fail! | 0
- :success | :succeed! | 1
- :success | :do_fail! | 0
- :failed | :succeed! | 1
- :failed | :do_fail! | 0
- :fixed | :succeed! | 1
- :fixed | :do_fail! | 0
- :broken | :succeed! | 1
- :broken | :do_fail! | 0
- :still_failing | :succeed | 1
- :still_failing | :do_fail | 0
- end
-
- with_them do
- context "when transitioning states" do
- before do
- status_value = Ci::Ref.state_machines[:status].states[initial_state].value
- ci_ref.update!(status: status_value)
- end
-
- it 'calls unlock artifacts service' do
- ci_ref.send(action)
-
- expect(unlock_artifacts_worker_spy).to have_received(:perform_async).exactly(count).times
- end
- end
- end
+ where(:initial_state, :action, :count) do
+ :unknown | :succeed! | 1
+ :unknown | :do_fail! | 0
+ :success | :succeed! | 1
+ :success | :do_fail! | 0
+ :failed | :succeed! | 1
+ :failed | :do_fail! | 0
+ :fixed | :succeed! | 1
+ :fixed | :do_fail! | 0
+ :broken | :succeed! | 1
+ :broken | :do_fail! | 0
+ :still_failing | :succeed | 1
+ :still_failing | :do_fail | 0
end
- context 'when keep latest artifact feature is not enabled' do
- before do
- stub_feature_flags(keep_latest_artifacts_for_ref: false)
- end
+ with_them do
+ context "when transitioning states" do
+ before do
+ status_value = Ci::Ref.state_machines[:status].states[initial_state].value
+ ci_ref.update!(status: status_value)
+ end
- it 'does not call unlock artifacts service' do
- ci_ref.succeed!
+ it 'calls unlock artifacts service' do
+ ci_ref.send(action)
- expect(unlock_artifacts_worker_spy).not_to have_received(:perform_async)
+ expect(unlock_artifacts_worker_spy).to have_received(:perform_async).exactly(count).times
+ end
end
end
end
@@ -125,8 +107,8 @@ RSpec.describe Ci::Ref do
describe '#last_finished_pipeline_id' do
let(:pipeline_status) { :running }
- let(:config_source) { Ci::PipelineEnums.config_sources[:repository_source] }
- let(:pipeline) { create(:ci_pipeline, pipeline_status, config_source: config_source) }
+ let(:pipeline_source) { Enums::Ci::Pipeline.sources[:push] }
+ let(:pipeline) { create(:ci_pipeline, pipeline_status, source: pipeline_source) }
let(:ci_ref) { pipeline.ci_ref }
context 'when there are no finished pipelines' do
@@ -142,8 +124,8 @@ RSpec.describe Ci::Ref do
expect(ci_ref.last_finished_pipeline_id).to eq(pipeline.id)
end
- context 'when the pipeline is not a ci_source' do
- let(:config_source) { Ci::PipelineEnums.config_sources[:parameter_source] }
+ context 'when the pipeline a dangling pipeline' do
+ let(:pipeline_source) { Enums::Ci::Pipeline.sources[:ondemand_dast_scan] }
it 'returns nil' do
expect(ci_ref.last_finished_pipeline_id).to be_nil
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index 8247ebf1144..3e5d068d780 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -570,7 +570,7 @@ RSpec.describe Ci::Runner do
let!(:last_update) { runner.ensure_runner_queue_value }
before do
- Ci::UpdateRunnerService.new(runner).update(description: 'new runner')
+ Ci::UpdateRunnerService.new(runner).update(description: 'new runner') # rubocop: disable Rails/SaveBang
end
it 'sets a new last_update value' do
@@ -660,7 +660,7 @@ RSpec.describe Ci::Runner do
before do
runner.tick_runner_queue
- runner.destroy
+ runner.destroy!
end
it 'cleans up the queue' do
@@ -878,7 +878,7 @@ RSpec.describe Ci::Runner do
it 'can be destroyed' do
subject
- expect { subject.destroy }.to change { described_class.count }.by(-1)
+ expect { subject.destroy! }.to change { described_class.count }.by(-1)
end
end
diff --git a/spec/models/ci_platform_metric_spec.rb b/spec/models/ci_platform_metric_spec.rb
new file mode 100644
index 00000000000..0b00875df43
--- /dev/null
+++ b/spec/models/ci_platform_metric_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe CiPlatformMetric do
+ subject { build(:ci_platform_metric) }
+
+ it_behaves_like 'a BulkInsertSafe model', CiPlatformMetric do
+ let(:valid_items_for_bulk_insertion) { build_list(:ci_platform_metric, 10) }
+ let(:invalid_items_for_bulk_insertion) { [] } # class does not have any non-constraint validations defined
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:recorded_at) }
+ it { is_expected.to validate_presence_of(:count) }
+ it { is_expected.to validate_numericality_of(:count).only_integer.is_greater_than(0) }
+ it { is_expected.to allow_values('').for(:platform_target) }
+ it { is_expected.not_to allow_values(nil).for(:platform_target) }
+ it { is_expected.to validate_length_of(:platform_target).is_at_most(255) }
+ end
+
+ describe '.insert_auto_devops_platform_targets!' do
+ def platform_target_counts_by_day
+ report = Hash.new { |hash, key| hash[key] = {} }
+ described_class.all.each do |metric|
+ date = metric.recorded_at.to_date
+ report[date][metric.platform_target] = metric.count
+ end
+ report
+ end
+
+ context 'when there is already existing metrics data' do
+ let!(:metric_1) { create(:ci_platform_metric) }
+ let!(:metric_2) { create(:ci_platform_metric) }
+
+ it 'does not erase any existing data' do
+ described_class.insert_auto_devops_platform_targets!
+
+ expect(described_class.all.to_a).to contain_exactly(metric_1, metric_2)
+ end
+ end
+
+ context 'when there are multiple platform target variables' do
+ let(:today) { Time.zone.local(1982, 4, 24) }
+ let(:tomorrow) { today + 1.day }
+
+ it 'inserts platform target counts for that day' do
+ Timecop.freeze(today) do
+ create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'ECS')
+ create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'ECS')
+ create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'FARGATE')
+ create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'FARGATE')
+ create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'FARGATE')
+ described_class.insert_auto_devops_platform_targets!
+ end
+ Timecop.freeze(tomorrow) do
+ create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'FARGATE')
+ described_class.insert_auto_devops_platform_targets!
+ end
+
+ expect(platform_target_counts_by_day).to eq({
+ today.to_date => { 'ECS' => 2, 'FARGATE' => 3 },
+ tomorrow.to_date => { 'ECS' => 2, 'FARGATE' => 4 }
+ })
+ end
+ end
+
+ context 'when there are invalid ci variable values for platform_target' do
+ let(:today) { Time.zone.local(1982, 4, 24) }
+
+ it 'ignores those values' do
+ Timecop.freeze(today) do
+ create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'ECS')
+ create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'FOO')
+ create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'BAR')
+ described_class.insert_auto_devops_platform_targets!
+ end
+
+ expect(platform_target_counts_by_day).to eq({
+ today.to_date => { 'ECS' => 1 }
+ })
+ end
+ end
+
+ context 'when there are no platform target variables' do
+ it 'does not generate any new platform metrics' do
+ create(:ci_variable, key: 'KEY_WHATEVER', value: 'ECS')
+ described_class.insert_auto_devops_platform_targets!
+
+ expect(platform_target_counts_by_day).to eq({})
+ end
+ end
+ end
+end
diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb
index bb1fc021e66..99de0d1ddf7 100644
--- a/spec/models/clusters/agent_spec.rb
+++ b/spec/models/clusters/agent_spec.rb
@@ -12,6 +12,17 @@ RSpec.describe Clusters::Agent do
it { is_expected.to validate_length_of(:name).is_at_most(63) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
+ describe 'scopes' do
+ describe '.with_name' do
+ let!(:matching_name) { create(:cluster_agent, name: 'matching-name') }
+ let!(:other_name) { create(:cluster_agent, name: 'other-name') }
+
+ subject { described_class.with_name(matching_name.name) }
+
+ it { is_expected.to contain_exactly(matching_name) }
+ end
+ end
+
describe 'validation' do
describe 'name validation' do
it 'rejects names that do not conform to RFC 1123', :aggregate_failures do
diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb
index 1215b38a9a2..82971596176 100644
--- a/spec/models/clusters/applications/prometheus_spec.rb
+++ b/spec/models/clusters/applications/prometheus_spec.rb
@@ -46,7 +46,7 @@ RSpec.describe Clusters::Applications::Prometheus do
subject { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
it 'sets last_update_started_at to now' do
- Timecop.freeze 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
@@ -109,10 +109,13 @@ RSpec.describe Clusters::Applications::Prometheus do
expect(subject.prometheus_client).to be_instance_of(Gitlab::PrometheusClient)
end
- it 'copies proxy_url, options and headers from kube client to prometheus_client' do
+ it 'merges proxy_url, options and headers from kube client with prometheus_client options' do
expect(Gitlab::PrometheusClient)
.to(receive(:new))
- .with(a_valid_url, kube_client.rest_client.options.merge(headers: kube_client.headers))
+ .with(a_valid_url, kube_client.rest_client.options.merge({
+ headers: kube_client.headers,
+ timeout: PrometheusAdapter::DEFAULT_PROMETHEUS_REQUEST_TIMEOUT_SEC
+ }))
subject.prometheus_client
end
@@ -150,7 +153,7 @@ RSpec.describe Clusters::Applications::Prometheus do
it 'is initialized with 3 arguments' do
expect(subject.name).to eq('prometheus')
expect(subject.chart).to eq('stable/prometheus')
- expect(subject.version).to eq('9.5.2')
+ expect(subject.version).to eq('10.4.1')
expect(subject).to be_rbac
expect(subject.files).to eq(prometheus.files)
end
@@ -167,7 +170,7 @@ RSpec.describe Clusters::Applications::Prometheus 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('9.5.2')
+ expect(subject.version).to eq('10.4.1')
end
end
@@ -238,7 +241,7 @@ RSpec.describe Clusters::Applications::Prometheus do
it 'is initialized with 3 arguments' do
expect(patch_command.name).to eq('prometheus')
expect(patch_command.chart).to eq('stable/prometheus')
- expect(patch_command.version).to eq('9.5.2')
+ expect(patch_command.version).to eq('10.4.1')
expect(patch_command.files).to eq(prometheus.files)
end
end
@@ -350,7 +353,7 @@ RSpec.describe Clusters::Applications::Prometheus do
let(:timestamp) { Time.current - 5.minutes }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
before do
diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index 2d0b5af0e77..024539e34ec 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -42,6 +42,7 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
it { is_expected.to delegate_method(:available?).to(:application_ingress).with_prefix }
it { is_expected.to delegate_method(:available?).to(:application_prometheus).with_prefix }
it { is_expected.to delegate_method(:available?).to(:application_knative).with_prefix }
+ it { is_expected.to delegate_method(:available?).to(:application_elastic_stack).with_prefix }
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 }
diff --git a/spec/models/clusters/kubernetes_namespace_spec.rb b/spec/models/clusters/kubernetes_namespace_spec.rb
index 2920bbf2b58..3b903fe34f9 100644
--- a/spec/models/clusters/kubernetes_namespace_spec.rb
+++ b/spec/models/clusters/kubernetes_namespace_spec.rb
@@ -61,7 +61,8 @@ RSpec.describe Clusters::KubernetesNamespace, type: :model do
end
describe 'namespace uniqueness validation' do
- let(:kubernetes_namespace) { build(:cluster_kubernetes_namespace, namespace: 'my-namespace') }
+ let_it_be(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:kubernetes_namespace) { build(:cluster_kubernetes_namespace, cluster: cluster, namespace: 'my-namespace') }
subject { kubernetes_namespace }
diff --git a/spec/models/commit_range_spec.rb b/spec/models/commit_range_spec.rb
index 3fb8708c884..334833e884b 100644
--- a/spec/models/commit_range_spec.rb
+++ b/spec/models/commit_range_spec.rb
@@ -3,25 +3,22 @@
require 'spec_helper'
RSpec.describe CommitRange do
+ let(:range2) { described_class.new("#{sha_from}..#{sha_to}", project) }
+ let(:range) { described_class.new("#{sha_from}...#{sha_to}", project) }
+ let(:full_sha_to) { commit2.id }
+ let(:full_sha_from) { commit1.id }
+ let(:sha_to) { commit2.short_id }
+ let(:sha_from) { commit1.short_id }
+ let!(:commit2) { project.commit }
+ let!(:commit1) { project.commit("HEAD~2") }
+ let!(:project) { create(:project, :public, :repository) }
+
describe 'modules' do
subject { described_class }
it { is_expected.to include_module(Referable) }
end
- let!(:project) { create(:project, :public, :repository) }
- let!(:commit1) { project.commit("HEAD~2") }
- let!(:commit2) { project.commit }
-
- let(:sha_from) { commit1.short_id }
- let(:sha_to) { commit2.short_id }
-
- let(:full_sha_from) { commit1.id }
- let(:full_sha_to) { commit2.id }
-
- let(:range) { described_class.new("#{sha_from}...#{sha_to}", project) }
- let(:range2) { described_class.new("#{sha_from}..#{sha_to}", project) }
-
it 'raises ArgumentError when given an invalid range string' do
expect { described_class.new("Foo", project) }.to raise_error(ArgumentError)
end
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index 7f893d6a100..6e23f95af03 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -494,6 +494,10 @@ RSpec.describe CommitStatus do
end
describe '#group_name' do
+ let(:commit_status) do
+ build(:commit_status, pipeline: pipeline, stage: 'test')
+ end
+
subject { commit_status.group_name }
tests = {
@@ -510,7 +514,19 @@ RSpec.describe CommitStatus do
'rspec:windows 0 : / 1' => 'rspec:windows',
'rspec:windows 0 : / 1 name' => 'rspec:windows name',
'0 1 name ruby' => 'name ruby',
- '0 :/ 1 name ruby' => 'name ruby'
+ '0 :/ 1 name ruby' => 'name ruby',
+ 'rspec: [aws]' => 'rspec: [aws]',
+ 'rspec: [aws] 0/1' => 'rspec: [aws]',
+ 'rspec: [aws, max memory]' => 'rspec',
+ 'rspec:linux: [aws, max memory, data]' => 'rspec:linux',
+ 'rspec: [inception: [something, other thing], value]' => 'rspec',
+ 'rspec:windows 0/1: [name, other]' => 'rspec:windows',
+ 'rspec:windows: [name, other] 0/1' => 'rspec:windows',
+ 'rspec:windows: [name, 0/1] 0/1' => 'rspec:windows',
+ 'rspec:windows: [0/1, name]' => 'rspec:windows',
+ 'rspec:windows: [, ]' => 'rspec:windows',
+ 'rspec:windows: [name]' => 'rspec:windows: [name]',
+ 'rspec:windows: [name,other]' => 'rspec:windows: [name,other]'
}
tests.each do |name, group_name|
diff --git a/spec/models/concerns/ci/artifactable_spec.rb b/spec/models/concerns/ci/artifactable_spec.rb
index 13c2ff5efe5..f05189abdd2 100644
--- a/spec/models/concerns/ci/artifactable_spec.rb
+++ b/spec/models/concerns/ci/artifactable_spec.rb
@@ -18,4 +18,40 @@ RSpec.describe Ci::Artifactable do
it { is_expected.to be_const_defined(:FILE_FORMAT_ADAPTERS) }
end
end
+
+ describe '#each_blob' do
+ context 'when file format is gzip' do
+ context 'when gzip file contains one file' do
+ let(:artifact) { build(:ci_job_artifact, :junit) }
+
+ it 'iterates blob once' do
+ expect { |b| artifact.each_blob(&b) }.to yield_control.once
+ end
+ end
+
+ context 'when gzip file contains three files' do
+ let(:artifact) { build(:ci_job_artifact, :junit_with_three_testsuites) }
+
+ it 'iterates blob three times' do
+ expect { |b| artifact.each_blob(&b) }.to yield_control.exactly(3).times
+ end
+ end
+ end
+
+ context 'when file format is raw' do
+ let(:artifact) { build(:ci_job_artifact, :codequality, file_format: :raw) }
+
+ it 'iterates blob once' do
+ expect { |b| artifact.each_blob(&b) }.to yield_control.once
+ end
+ end
+
+ context 'when there are no adapters for the file format' do
+ let(:artifact) { build(:ci_job_artifact, :junit, file_format: :zip) }
+
+ it 'raises an error' do
+ expect { |b| artifact.each_blob(&b) }.to raise_error(described_class::NotSupportedAdapterError)
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/from_except_spec.rb b/spec/models/concerns/from_except_spec.rb
new file mode 100644
index 00000000000..3b081b4f572
--- /dev/null
+++ b/spec/models/concerns/from_except_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FromExcept do
+ it_behaves_like 'from set operator', Gitlab::SQL::Except
+end
diff --git a/spec/models/concerns/from_intersect_spec.rb b/spec/models/concerns/from_intersect_spec.rb
new file mode 100644
index 00000000000..78884f8ae56
--- /dev/null
+++ b/spec/models/concerns/from_intersect_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FromIntersect do
+ it_behaves_like 'from set operator', Gitlab::SQL::Intersect
+end
diff --git a/spec/models/concerns/from_set_operator_spec.rb b/spec/models/concerns/from_set_operator_spec.rb
new file mode 100644
index 00000000000..8ebbb5550c9
--- /dev/null
+++ b/spec/models/concerns/from_set_operator_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FromSetOperator do
+ describe 'when set operator method already exists' do
+ let(:redefine_method) do
+ Class.new do
+ def self.from_union
+ # This method intentionally left blank.
+ end
+
+ extend FromSetOperator
+ define_set_operator Gitlab::SQL::Union
+ end
+ end
+
+ it { expect { redefine_method }.to raise_exception(RuntimeError) }
+ end
+end
diff --git a/spec/models/concerns/from_union_spec.rb b/spec/models/concerns/from_union_spec.rb
index 9819a6ec3de..bd2893090a8 100644
--- a/spec/models/concerns/from_union_spec.rb
+++ b/spec/models/concerns/from_union_spec.rb
@@ -3,38 +3,13 @@
require 'spec_helper'
RSpec.describe FromUnion do
- describe '.from_union' do
- let(:model) do
- Class.new(ActiveRecord::Base) do
- self.table_name = 'users'
-
- include FromUnion
+ [true, false].each do |sql_set_operator|
+ context "when sql-set-operators feature flag is #{sql_set_operator}" do
+ before do
+ stub_feature_flags(sql_set_operators: sql_set_operator)
end
- end
-
- it 'selects from the results of the UNION' do
- query = model.from_union([model.where(id: 1), model.where(id: 2)])
-
- expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\nUNION\n\(SELECT.+\)\) users/m)
- end
-
- it 'supports the use of a custom alias for the sub query' do
- query = model.from_union(
- [model.where(id: 1), model.where(id: 2)],
- alias_as: 'kittens'
- )
-
- expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\nUNION\n\(SELECT.+\)\) kittens/m)
- end
-
- it 'supports keeping duplicate rows' do
- query = model.from_union(
- [model.where(id: 1), model.where(id: 2)],
- remove_duplicates: false
- )
- expect(query.to_sql)
- .to match(/FROM \(\(SELECT.+\)\nUNION ALL\n\(SELECT.+\)\) users/m)
+ it_behaves_like 'from set operator', Gitlab::SQL::Union
end
end
end
diff --git a/spec/models/concerns/id_in_ordered_spec.rb b/spec/models/concerns/id_in_ordered_spec.rb
new file mode 100644
index 00000000000..a3b434caac6
--- /dev/null
+++ b/spec/models/concerns/id_in_ordered_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IdInOrdered do
+ describe 'Issue' do
+ describe '.id_in_ordered' do
+ it 'returns issues matching the ids in the same order as the ids' do
+ issue1 = create(:issue)
+ issue2 = create(:issue)
+ issue3 = create(:issue)
+ issue4 = create(:issue)
+ issue5 = create(:issue)
+
+ expect(Issue.id_in_ordered([issue3.id, issue1.id, issue4.id, issue5.id, issue2.id])).to eq([
+ issue3, issue1, issue4, issue5, issue2
+ ])
+ end
+
+ context 'when the ids are not an array of integers' do
+ it 'raises ArgumentError' do
+ expect { Issue.id_in_ordered([1, 'no SQL injection']) }.to raise_error(ArgumentError, "ids must be an array of integers")
+ end
+ end
+
+ context 'when an empty array is given' do
+ it 'does not raise error' do
+ expect(Issue.id_in_ordered([]).to_a).to be_empty
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 0824b5c7834..44561e2e55a 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -295,20 +295,14 @@ RSpec.describe Issuable do
end
describe "#new?" do
- it "returns true when created today and record hasn't been updated" do
- allow(issue).to receive(:today?).and_return(true)
- expect(issue.new?).to be_truthy
- end
-
- it "returns false when not created today" do
- allow(issue).to receive(:today?).and_return(false)
+ it "returns false when created 30 hours ago" do
+ allow(issue).to receive(:created_at).and_return(Time.current - 30.hours)
expect(issue.new?).to be_falsey
end
- it "returns false when record has been updated" do
- allow(issue).to receive(:today?).and_return(true)
- issue.update_attribute(:updated_at, 1.hour.ago)
- expect(issue.new?).to be_falsey
+ it "returns true when created 20 hours ago" do
+ allow(issue).to receive(:created_at).and_return(Time.current - 20.hours)
+ expect(issue.new?).to be_truthy
end
end
@@ -824,4 +818,166 @@ RSpec.describe Issuable do
it_behaves_like 'matches_cross_reference_regex? fails fast'
end
end
+
+ describe '#supports_time_tracking?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:issuable_type, :supports_time_tracking) do
+ :issue | true
+ :incident | false
+ :merge_request | true
+ end
+
+ with_them do
+ let(:issuable) { build_stubbed(issuable_type) }
+
+ subject { issuable.supports_time_tracking? }
+
+ it { is_expected.to eq(supports_time_tracking) }
+ end
+ end
+
+ describe '#supports_severity?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:issuable_type, :supports_severity) do
+ :issue | false
+ :incident | true
+ :merge_request | false
+ end
+
+ with_them do
+ let(:issuable) { build_stubbed(issuable_type) }
+
+ subject { issuable.supports_severity? }
+
+ it { is_expected.to eq(supports_severity) }
+ end
+ end
+
+ describe '#incident?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:issuable_type, :incident) do
+ :issue | false
+ :incident | true
+ :merge_request | false
+ end
+
+ with_them do
+ let(:issuable) { build_stubbed(issuable_type) }
+
+ subject { issuable.incident? }
+
+ it { is_expected.to eq(incident) }
+ end
+ end
+
+ describe '#supports_issue_type?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:issuable_type, :supports_issue_type) do
+ :issue | true
+ :merge_request | false
+ end
+
+ with_them do
+ let(:issuable) { build_stubbed(issuable_type) }
+
+ subject { issuable.supports_issue_type? }
+
+ it { is_expected.to eq(supports_issue_type) }
+ end
+ end
+
+ describe '#severity' do
+ subject { issuable.severity }
+
+ context 'when issuable is not an incident' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:issuable_type, :severity) do
+ :issue | 'unknown'
+ :merge_request | 'unknown'
+ end
+
+ with_them do
+ let(:issuable) { build_stubbed(issuable_type) }
+
+ it { is_expected.to eq(severity) }
+ end
+ end
+
+ context 'when issuable type is an incident' do
+ let!(:issuable) { build_stubbed(:incident) }
+
+ context 'when incident does not have issuable_severity' do
+ it 'returns default serverity' do
+ is_expected.to eq(IssuableSeverity::DEFAULT)
+ end
+ end
+
+ context 'when incident has issuable_severity' do
+ let!(:issuable_severity) { build_stubbed(:issuable_severity, issue: issuable, severity: 'critical') }
+
+ it 'returns issuable serverity' do
+ is_expected.to eq('critical')
+ end
+ end
+ end
+ end
+
+ describe '#update_severity' do
+ let(:severity) { 'low' }
+
+ subject(:update_severity) { issuable.update_severity(severity) }
+
+ context 'when issuable not an incident' do
+ %i(issue merge_request).each do |issuable_type|
+ let(:issuable) { build_stubbed(issuable_type) }
+
+ it { is_expected.to be_nil }
+
+ it 'does not set severity' do
+ expect { subject }.not_to change(IssuableSeverity, :count)
+ end
+ end
+ end
+
+ context 'when issuable is an incident' do
+ let!(:issuable) { create(:incident) }
+
+ context 'when issuable does not have issuable severity yet' do
+ it 'creates new record' do
+ expect { update_severity }.to change { IssuableSeverity.where(issue: issuable).count }.to(1)
+ end
+
+ it 'sets severity to specified value' do
+ expect { update_severity }.to change { issuable.severity }.to('low')
+ end
+ end
+
+ context 'when issuable has an issuable severity' do
+ let!(:issuable_severity) { create(:issuable_severity, issue: issuable, severity: 'medium') }
+
+ it 'does not create new record' do
+ expect { update_severity }.not_to change(IssuableSeverity, :count)
+ end
+
+ it 'updates existing issuable severity' do
+ expect { update_severity }.to change { issuable_severity.severity }.to(severity)
+ end
+ end
+
+ context 'when severity value is unsupported' do
+ let(:severity) { 'unsupported-severity' }
+
+ it 'sets the severity to default value' do
+ update_severity
+
+ expect(issuable.issuable_severity.severity).to eq(IssuableSeverity::DEFAULT)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/milestoneable_spec.rb b/spec/models/concerns/milestoneable_spec.rb
index 3dd6f1450c7..f5b82e42ad4 100644
--- a/spec/models/concerns/milestoneable_spec.rb
+++ b/spec/models/concerns/milestoneable_spec.rb
@@ -100,6 +100,14 @@ RSpec.describe Milestoneable do
expect(merge_request.supports_milestone?).to be_truthy
end
end
+
+ context "for incidents" do
+ let(:incident) { build(:incident) }
+
+ it 'returns false' do
+ expect(incident.supports_milestone?).to be_falsy
+ end
+ end
end
describe 'release scopes' do
diff --git a/spec/models/concerns/prometheus_adapter_spec.rb b/spec/models/concerns/prometheus_adapter_spec.rb
index e795e2b06cb..235e505c6e9 100644
--- a/spec/models/concerns/prometheus_adapter_spec.rb
+++ b/spec/models/concerns/prometheus_adapter_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe PrometheusAdapter, :use_clean_rails_memory_store_caching do
let(:validation_respone) { { data: { valid: true } } }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'with valid data' do
@@ -45,7 +45,7 @@ RSpec.describe PrometheusAdapter, :use_clean_rails_memory_store_caching do
let(:environment) { build_stubbed(:environment, slug: 'env-slug') }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'with valid data' do
@@ -85,7 +85,7 @@ RSpec.describe PrometheusAdapter, :use_clean_rails_memory_store_caching do
let(:deployment_query) { Gitlab::Prometheus::Queries::DeploymentQuery }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'with valid data' do
@@ -107,7 +107,7 @@ RSpec.describe PrometheusAdapter, :use_clean_rails_memory_store_caching do
let(:time_window) { [1552642245.067, 1552642095.831] }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'with valid data' do
@@ -137,7 +137,7 @@ RSpec.describe PrometheusAdapter, :use_clean_rails_memory_store_caching do
end
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'when service is inactive' do
diff --git a/spec/models/cycle_analytics/issue_spec.rb b/spec/models/cycle_analytics/issue_spec.rb
index 9372ef5f0e6..5857365ceab 100644
--- a/spec/models/cycle_analytics/issue_spec.rb
+++ b/spec/models/cycle_analytics/issue_spec.rb
@@ -15,17 +15,17 @@ RSpec.describe 'CycleAnalytics#issue' do
generate_cycle_analytics_spec(
phase: :issue,
data_fn: -> (context) { { issue: context.build(:issue, project: context.project) } },
- start_time_conditions: [["issue created", -> (context, data) { data[:issue].save }]],
+ start_time_conditions: [["issue created", -> (context, data) { data[:issue].save! }]],
end_time_conditions: [["issue associated with a milestone",
-> (context, data) do
if data[:issue].persisted?
- data[:issue].update(milestone: context.create(:milestone, project: context.project))
+ data[:issue].update!(milestone: context.create(:milestone, project: context.project))
end
end],
["list label added to issue",
-> (context, data) do
if data[:issue].persisted?
- data[:issue].update(label_ids: [context.create(:list).label_id])
+ data[:issue].update!(label_ids: [context.create(:list).label_id])
end
end]],
post_fn: -> (context, data) do
@@ -35,7 +35,7 @@ RSpec.describe 'CycleAnalytics#issue' do
it "returns nil" do
regular_label = create(:label)
issue = create(:issue, project: project)
- issue.update(label_ids: [regular_label.id])
+ issue.update!(label_ids: [regular_label.id])
create_merge_request_closing_issue(user, project, issue)
merge_merge_requests_closing_issue(user, project, issue)
diff --git a/spec/models/cycle_analytics/plan_spec.rb b/spec/models/cycle_analytics/plan_spec.rb
index 364694a11e1..2b9be64da2b 100644
--- a/spec/models/cycle_analytics/plan_spec.rb
+++ b/spec/models/cycle_analytics/plan_spec.rb
@@ -22,11 +22,11 @@ RSpec.describe 'CycleAnalytics#plan' do
end,
start_time_conditions: [["issue associated with a milestone",
-> (context, data) do
- data[:issue].update(milestone: context.create(:milestone, project: context.project))
+ data[:issue].update!(milestone: context.create(:milestone, project: context.project))
end],
["list label added to issue",
-> (context, data) do
- data[:issue].update(label_ids: [context.create(:list).label_id])
+ data[:issue].update!(label_ids: [context.create(:list).label_id])
end]],
end_time_conditions: [["issue mentioned in a commit",
-> (context, data) do
@@ -40,7 +40,7 @@ RSpec.describe 'CycleAnalytics#plan' do
branch_name = generate(:branch)
label = create(:label)
issue = create(:issue, project: project)
- issue.update(label_ids: [label.id])
+ issue.update!(label_ids: [label.id])
create_commit_referencing_issue(issue, branch_name: branch_name)
create_merge_request_closing_issue(user, project, issue, source_branch: branch_name)
diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb
deleted file mode 100644
index cf4d57d6b73..00000000000
--- a/spec/models/cycle_analytics/production_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'CycleAnalytics#production' do
- extend CycleAnalyticsHelpers::TestGeneration
-
- let_it_be(:project) { create(:project, :repository) }
- let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { project.owner }
- let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) }
-
- subject { project_level }
-
- generate_cycle_analytics_spec(
- phase: :production,
- data_fn: -> (context) { { issue: context.build(:issue, project: context.project) } },
- start_time_conditions: [["issue is created", -> (context, data) { data[:issue].save }]],
- before_end_fn: lambda do |context, data|
- context.create_merge_request_closing_issue(context.user, context.project, data[:issue])
- context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue])
- end,
- end_time_conditions:
- [["merge request that closes issue is deployed to production", -> (context, data) { context.deploy_master(context.user, context.project) }],
- ["production deploy happens after merge request is merged (along with other changes)",
- lambda do |context, data|
- # Make other changes on master
- context.project.repository.commit("sha_that_does_not_matter")
-
- context.deploy_master(context.user, context.project)
- end]])
-
- context "when a regular merge request (that doesn't close the issue) is merged and deployed" do
- it "returns nil" do
- merge_request = create(:merge_request)
- MergeRequests::MergeService.new(project, user).execute(merge_request)
- deploy_master(user, project)
-
- expect(subject[:production].project_median).to be_nil
- end
- end
-
- context "when the deployment happens to a non-production environment" do
- it "returns nil" do
- issue = build(:issue, project: project)
- merge_request = create_merge_request_closing_issue(user, project, issue)
- MergeRequests::MergeService.new(project, user).execute(merge_request)
- deploy_master(user, project, environment: 'staging')
-
- expect(subject[:production].project_median).to be_nil
- end
- end
-end
diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb
index b320390711e..1c7b11257ce 100644
--- a/spec/models/deployment_spec.rb
+++ b/spec/models/deployment_spec.rb
@@ -44,10 +44,14 @@ RSpec.describe Deployment do
describe 'modules' do
it_behaves_like 'AtomicInternalId' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:deployable) { create(:ci_build, project: project) }
+ let_it_be(:environment) { create(:environment, project: project) }
+
let(:internal_id_attribute) { :iid }
- let(:instance) { build(:deployment) }
+ let(:instance) { build(:deployment, deployable: deployable, environment: environment) }
let(:scope) { :project }
- let(:scope_attrs) { { project: instance.project } }
+ let(:scope_attrs) { { project: project } }
let(:usage) { :deployments }
end
end
@@ -99,7 +103,7 @@ RSpec.describe Deployment do
end
it 'starts running' do
- Timecop.freeze do
+ freeze_time do
expect(deployment).to be_running
expect(deployment.finished_at).to be_nil
end
@@ -110,7 +114,7 @@ RSpec.describe Deployment do
let(:deployment) { create(:deployment, :running) }
it 'has correct status' do
- Timecop.freeze do
+ freeze_time do
deployment.succeed!
expect(deployment).to be_success
@@ -137,7 +141,7 @@ RSpec.describe Deployment do
let(:deployment) { create(:deployment, :running) }
it 'has correct status' do
- Timecop.freeze do
+ freeze_time do
deployment.drop!
expect(deployment).to be_failed
@@ -157,7 +161,7 @@ RSpec.describe Deployment do
let(:deployment) { create(:deployment, :running) }
it 'has correct status' do
- Timecop.freeze do
+ freeze_time do
deployment.cancel!
expect(deployment).to be_canceled
@@ -584,7 +588,7 @@ RSpec.describe Deployment do
end
it 'updates finished_at when transitioning to a finished status' do
- Timecop.freeze do
+ freeze_time do
deploy.update_status('success')
expect(deploy.read_attribute(:finished_at)).to eq(Time.current)
diff --git a/spec/models/design_management/design_collection_spec.rb b/spec/models/design_management/design_collection_spec.rb
index de766d5ce09..8575cc80b5b 100644
--- a/spec/models/design_management/design_collection_spec.rb
+++ b/spec/models/design_management/design_collection_spec.rb
@@ -3,8 +3,9 @@ require 'spec_helper'
RSpec.describe DesignManagement::DesignCollection do
include DesignManagementTestHelpers
+ using RSpec::Parameterized::TableSyntax
- let_it_be(:issue, reload: true) { create(:issue) }
+ let_it_be(:issue, refind: true) { create(:issue) }
subject(:collection) { described_class.new(issue) }
@@ -41,7 +42,62 @@ RSpec.describe DesignManagement::DesignCollection do
design2 = collection.find_or_create_design!(filename: 'design2.jpg')
- expect(collection.designs.ordered(issue.project)).to eq([design1, design2])
+ expect(collection.designs.ordered).to eq([design1, design2])
+ end
+ end
+
+ describe "#copy_state", :clean_gitlab_redis_shared_state do
+ it "defaults to ready" do
+ expect(collection).to be_copy_ready
+ end
+
+ it "persists its state changes between initializations" do
+ collection.start_copy!
+
+ expect(described_class.new(issue)).to be_copy_in_progress
+ end
+
+ where(:state, :can_start, :can_end, :can_error, :can_reset) do
+ "ready" | true | false | true | true
+ "in_progress" | false | true | true | true
+ "error" | false | false | false | true
+ end
+
+ with_them do
+ it "maintains state machine transition rules", :aggregate_failures do
+ collection.copy_state = state
+
+ expect(collection.can_start_copy?).to eq(can_start)
+ expect(collection.can_end_copy?).to eq(can_end)
+ end
+ end
+
+ describe "clearing the redis cached state when state changes back to ready" do
+ def redis_copy_state
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.get(collection.send(:copy_state_cache_key))
+ end
+ end
+
+ def fire_state_events(*events)
+ events.each do |event|
+ collection.fire_copy_state_event(event)
+ end
+ end
+
+ it "clears the cached state on end_copy!", :aggregate_failures do
+ fire_state_events(:start)
+
+ expect { collection.end_copy! }.to change { redis_copy_state }.from("in_progress").to(nil)
+ expect(collection).to be_copy_ready
+ end
+
+ it "clears the cached state on reset_copy!", :aggregate_failures do
+ fire_state_events(:start, :error)
+
+ expect { collection.reset_copy! }.to change { redis_copy_state }.from("error").to(nil)
+ expect(collection).to be_copy_ready
+ end
end
end
diff --git a/spec/models/design_management/design_spec.rb b/spec/models/design_management/design_spec.rb
index 2c129f883b9..d4adc0d42d0 100644
--- a/spec/models/design_management/design_spec.rb
+++ b/spec/models/design_management/design_spec.rb
@@ -12,8 +12,10 @@ RSpec.describe DesignManagement::Design do
let_it_be(:deleted_design) { create(:design, :with_versions, deleted: true) }
it_behaves_like 'a class that supports relative positioning' do
+ let_it_be(:relative_parent) { create(:issue) }
+
let(:factory) { :design }
- let(:default_params) { { issue: issue } }
+ let(:default_params) { { issue: relative_parent } }
end
describe 'relations' do
@@ -161,27 +163,7 @@ RSpec.describe DesignManagement::Design do
end
it 'sorts by relative position and ID in ascending order' do
- expect(described_class.ordered(issue.project)).to eq([design2, design1, design3, deleted_design])
- end
-
- context 'when the :reorder_designs feature is enabled for the project' do
- before do
- stub_feature_flags(reorder_designs: issue.project)
- end
-
- it 'sorts by relative position and ID in ascending order' do
- expect(described_class.ordered(issue.project)).to eq([design2, design1, design3, deleted_design])
- end
- end
-
- context 'when the :reorder_designs feature is disabled' do
- before do
- stub_feature_flags(reorder_designs: false)
- end
-
- it 'sorts by ID in ascending order' do
- expect(described_class.ordered(issue.project)).to eq([design1, design2, design3, deleted_design])
- end
+ expect(described_class.ordered).to eq([design2, design1, design3, deleted_design])
end
end
diff --git a/spec/models/dev_ops_score/metric_spec.rb b/spec/models/dev_ops_report/metric_spec.rb
index 60001d0667d..191692f43a4 100644
--- a/spec/models/dev_ops_score/metric_spec.rb
+++ b/spec/models/dev_ops_report/metric_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe DevOpsScore::Metric do
- let(:conv_dev_index) { create(:dev_ops_score_metric) }
+RSpec.describe DevOpsReport::Metric do
+ let(:conv_dev_index) { create(:dev_ops_report_metric) }
describe '#percentage_score' do
it 'returns stored percentage score' do
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index 8a6176bf045..f7ce44f7281 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -35,7 +35,9 @@ RSpec.describe DiffNote do
subject { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) }
describe 'validations' do
- it_behaves_like 'a valid diff positionable note', :diff_note_on_commit
+ it_behaves_like 'a valid diff positionable note' do
+ subject { build(:diff_note_on_commit, project: project, commit_id: commit_id, position: position) }
+ end
end
describe "#position=" do
diff --git a/spec/models/draft_note_spec.rb b/spec/models/draft_note_spec.rb
index 64b06bf5c8f..580a588ae1d 100644
--- a/spec/models/draft_note_spec.rb
+++ b/spec/models/draft_note_spec.rb
@@ -5,11 +5,13 @@ require 'spec_helper'
RSpec.describe DraftNote do
include RepoHelpers
- let(:project) { create(:project, :repository) }
- let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
describe 'validations' do
- it_behaves_like 'a valid diff positionable note', :draft_note
+ it_behaves_like 'a valid diff positionable note' do
+ subject { build(:draft_note, merge_request: merge_request, commit_id: commit_id, position: position) }
+ end
end
describe 'delegations' do
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 2696d144db4..433ede97b82 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -854,8 +854,8 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
it 'overrides reactive_cache_limit_enabled? with a FF' do
- environment_with_enabled_ff = FactoryBot.build(:environment)
- environment_with_disabled_ff = FactoryBot.build(:environment)
+ environment_with_enabled_ff = build(:environment, project: create(:project))
+ environment_with_disabled_ff = build(:environment, project: create(:project))
stub_feature_flags(reactive_caching_limit_environment: environment_with_enabled_ff.project)
@@ -1222,7 +1222,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
let(:environment) { build(:environment, :will_auto_stop) }
it 'returns when it will expire' do
- Timecop.freeze { is_expected.to eq(1.day.to_i) }
+ freeze_time { is_expected.to eq(1.day.to_i) }
end
end
@@ -1248,7 +1248,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
with_them do
it 'sets correct auto_stop_in' do
- Timecop.freeze do
+ freeze_time do
if expected_result.is_a?(Integer) || expected_result.nil?
subject
diff --git a/spec/models/environment_status_spec.rb b/spec/models/environment_status_spec.rb
index 7eefb8f714a..a6954fb5d56 100644
--- a/spec/models/environment_status_spec.rb
+++ b/spec/models/environment_status_spec.rb
@@ -90,22 +90,6 @@ RSpec.describe EnvironmentStatus do
end
end
- describe '.after_merge_request' do
- let(:admin) { create(:admin) }
- let(:pipeline) { create(:ci_pipeline, sha: sha) }
-
- before do
- merge_request.mark_as_merged!
- end
-
- it 'is based on merge_request.merge_commit_sha' do
- expect(merge_request).to receive(:merge_commit_sha)
- expect(merge_request).not_to receive(:diff_head_sha)
-
- described_class.after_merge_request(merge_request, admin)
- end
- end
-
describe '.for_deployed_merge_request' do
context 'when a merge request has no explicitly linked deployments' do
it 'returns the statuses based on the CI pipelines' do
@@ -191,7 +175,7 @@ RSpec.describe EnvironmentStatus do
let(:environment) { build.deployment.environment }
let(:user) { project.owner }
- context 'when environment is created on a forked project' do
+ context 'when environment is created on a forked project', :sidekiq_inline do
let(:project) { create(:project, :repository) }
let(:forked) { fork_project(project, user, repository: true) }
let(:sha) { forked.commit.sha }
@@ -205,7 +189,7 @@ RSpec.describe EnvironmentStatus do
head_pipeline: pipeline)
end
- it 'returns environment status', :sidekiq_might_not_need_inline do
+ it 'returns environment status' do
expect(subject.count).to eq(1)
expect(subject[0].environment).to eq(environment)
expect(subject[0].merge_request).to eq(merge_request)
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index 015a86cb28b..bafcb7a3741 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -81,6 +81,8 @@ RSpec.describe Event do
describe 'validations' do
describe 'action' do
context 'for a design' do
+ let_it_be(:author) { create(:user) }
+
where(:action, :valid) do
valid = described_class::DESIGN_ACTIONS.map(&:to_s).to_set
@@ -90,7 +92,7 @@ RSpec.describe Event do
end
with_them do
- let(:event) { build(:design_event, action: action) }
+ let(:event) { build(:design_event, author: author, action: action) }
specify { expect(event.valid?).to eq(valid) }
end
@@ -722,9 +724,17 @@ RSpec.describe Event do
note_on_commit: true
}
valid_target_factories.map do |kind, needs_project|
- extra_data = needs_project ? { project: project } : {}
+ extra_data = if kind == :merge_request
+ { source_project: project }
+ elsif needs_project
+ { project: project }
+ else
+ {}
+ end
+
target = kind == :project ? nil : build(kind, **extra_data)
- [kind, build(:event, :created, project: project, target: target)]
+
+ [kind, build(:event, :created, author: project.owner, project: project, target: target)]
end.to_h
end
diff --git a/spec/models/group_deploy_key_spec.rb b/spec/models/group_deploy_key_spec.rb
index 6757c5534ce..dfb4fee593f 100644
--- a/spec/models/group_deploy_key_spec.rb
+++ b/spec/models/group_deploy_key_spec.rb
@@ -82,4 +82,25 @@ RSpec.describe GroupDeployKey do
end
end
end
+
+ describe '.for_groups' do
+ context 'when group deploy keys are enabled for some groups' do
+ let_it_be(:group1) { create(:group) }
+ let_it_be(:group2) { create(:group) }
+ let_it_be(:group3) { create(:group) }
+ let_it_be(:gdk1) { create(:group_deploy_key) }
+ let_it_be(:gdk2) { create(:group_deploy_key) }
+ let_it_be(:gdk3) { create(:group_deploy_key) }
+
+ it 'returns these group deploy keys' do
+ gdk1.groups << group1
+ gdk1.groups << group2
+ gdk2.groups << group3
+ gdk3.groups << group2
+
+ expect(described_class.for_groups([group1.id, group3.id])).to contain_exactly(gdk1, gdk2)
+ expect(described_class.for_groups([group2.id])).to contain_exactly(gdk1, gdk3)
+ end
+ end
+ end
end
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 3eb74da09e1..15972f66fd6 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -27,6 +27,7 @@ RSpec.describe Group do
it { is_expected.to have_many(:milestones) }
it { is_expected.to have_many(:iterations) }
it { is_expected.to have_many(:group_deploy_keys) }
+ it { is_expected.to have_many(:services) }
describe '#members & #requesters' do
let(:requester) { create(:user) }
@@ -652,6 +653,19 @@ RSpec.describe Group do
expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::MAINTAINER)
end
end
+
+ context 'evaluating admin access level' do
+ let_it_be(:admin) { create(:admin) }
+
+ it 'returns OWNER by default' do
+ expect(group.max_member_access_for_user(admin)).to eq(Gitlab::Access::OWNER)
+ end
+
+ it 'returns NO_ACCESS when only concrete membership should be considered' do
+ expect(group.max_member_access_for_user(admin, only_concrete_membership: true))
+ .to eq(Gitlab::Access::NO_ACCESS)
+ end
+ end
end
describe '#members_with_parents' do
@@ -692,6 +706,7 @@ RSpec.describe Group do
before do
create(:group_member, user: user, group: group_parent, access_level: parent_group_access_level)
create(:group_member, user: user, group: group, access_level: group_access_level)
+ create(:group_member, :minimal_access, user: create(:user), source: group)
create(:group_member, user: user, group: group_child, access_level: child_group_access_level)
end
diff --git a/spec/models/issuable_severity_spec.rb b/spec/models/issuable_severity_spec.rb
new file mode 100644
index 00000000000..4dfd19756cb
--- /dev/null
+++ b/spec/models/issuable_severity_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssuableSeverity, type: :model do
+ let_it_be(:issuable_severity) { create(:issuable_severity) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:issue) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:severity) }
+ it { is_expected.to validate_presence_of(:issue) }
+ it { is_expected.to validate_uniqueness_of(:issue) }
+ end
+
+ describe 'enums' do
+ let(:severity_values) do
+ { unknown: 0, low: 1, medium: 2, high: 3, critical: 4 }
+ end
+
+ it { is_expected.to define_enum_for(:severity).with_values(severity_values) }
+ end
+end
diff --git a/spec/models/issue_link_spec.rb b/spec/models/issue_link_spec.rb
new file mode 100644
index 00000000000..00791d4a48b
--- /dev/null
+++ b/spec/models/issue_link_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssueLink do
+ describe 'Associations' do
+ it { is_expected.to belong_to(:source).class_name('Issue') }
+ it { is_expected.to belong_to(:target).class_name('Issue') }
+ end
+
+ describe 'link_type' do
+ it { is_expected.to define_enum_for(:link_type).with_values(relates_to: 0, blocks: 1, is_blocked_by: 2) }
+
+ it 'provides the "related" as default link_type' do
+ expect(create(:issue_link).link_type).to eq 'relates_to'
+ end
+ end
+
+ describe 'Validation' do
+ subject { create :issue_link }
+
+ it { is_expected.to validate_presence_of(:source) }
+ it { is_expected.to validate_presence_of(:target) }
+ it do
+ is_expected.to validate_uniqueness_of(:source)
+ .scoped_to(:target_id)
+ .with_message(/already related/)
+ end
+
+ context 'self relation' do
+ let(:issue) { create :issue }
+
+ context 'cannot be validated' do
+ it 'does not invalidate object with self relation error' do
+ issue_link = build :issue_link, source: issue, target: nil
+
+ issue_link.valid?
+
+ expect(issue_link.errors[:source]).to be_empty
+ end
+ end
+
+ context 'can be invalidated' do
+ it 'invalidates object' do
+ issue_link = build :issue_link, source: issue, target: issue
+
+ expect(issue_link).to be_invalid
+ expect(issue_link.errors[:source]).to include('cannot be related to itself')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 59634524e74..283d945157b 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -133,6 +133,7 @@ RSpec.describe Issue do
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:test_case) { create(:quality_test_case, project: project) }
it 'gives issues with the given issue type' do
expect(described_class.with_issue_type('issue'))
@@ -140,8 +141,8 @@ RSpec.describe Issue do
end
it 'gives issues with the given issue type' do
- expect(described_class.with_issue_type(%w(issue incident)))
- .to contain_exactly(issue, incident)
+ expect(described_class.with_issue_type(%w(issue incident test_case)))
+ .to contain_exactly(issue, incident, test_case)
end
end
@@ -311,6 +312,50 @@ RSpec.describe Issue do
end
end
+ describe '#related_issues' do
+ let(:user) { create(:user) }
+ let(:authorized_project) { create(:project) }
+ let(:authorized_project2) { create(:project) }
+ let(:unauthorized_project) { create(:project) }
+
+ let(:authorized_issue_a) { create(:issue, project: authorized_project) }
+ let(:authorized_issue_b) { create(:issue, project: authorized_project) }
+ let(:authorized_issue_c) { create(:issue, project: authorized_project2) }
+
+ let(:unauthorized_issue) { create(:issue, project: unauthorized_project) }
+
+ let!(:issue_link_a) { create(:issue_link, source: authorized_issue_a, target: authorized_issue_b) }
+ let!(:issue_link_b) { create(:issue_link, source: authorized_issue_a, target: unauthorized_issue) }
+ let!(:issue_link_c) { create(:issue_link, source: authorized_issue_a, target: authorized_issue_c) }
+
+ before do
+ authorized_project.add_developer(user)
+ authorized_project2.add_developer(user)
+ end
+
+ it 'returns only authorized related issues for given user' do
+ expect(authorized_issue_a.related_issues(user))
+ .to contain_exactly(authorized_issue_b, authorized_issue_c)
+ end
+
+ it 'returns issues with valid issue_link_type' do
+ link_types = authorized_issue_a.related_issues(user).map(&:issue_link_type)
+
+ expect(link_types).not_to be_empty
+ expect(link_types).not_to include(nil)
+ end
+
+ describe 'when a user cannot read cross project' do
+ it 'only returns issues within the same project' do
+ expect(Ability).to receive(:allowed?).with(user, :read_all_resources, :global).at_least(:once).and_call_original
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project).and_return(false)
+
+ expect(authorized_issue_a.related_issues(user))
+ .to contain_exactly(authorized_issue_b)
+ end
+ end
+ end
+
describe '#can_move?' do
let(:issue) { create(:issue) }
@@ -1139,4 +1184,33 @@ RSpec.describe Issue do
expect(context[:label_url_method]).to eq(:project_issues_url)
end
end
+
+ describe 'scheduling rebalancing' do
+ before do
+ allow_next_instance_of(RelativePositioning::Mover) do |mover|
+ allow(mover).to receive(:move) { raise ActiveRecord::QueryCanceled }
+ end
+ end
+
+ let(:project) { build_stubbed(:project_empty_repo) }
+ let(:issue) { build_stubbed(:issue, relative_position: 100, project: project) }
+
+ it 'schedules rebalancing if we time-out when moving' do
+ lhs = build_stubbed(:issue, relative_position: 99, project: project)
+ to_move = build(:issue, project: project)
+ expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project.id)
+
+ expect { to_move.move_between(lhs, issue) }.to raise_error(ActiveRecord::QueryCanceled)
+ end
+ end
+
+ describe '#allows_reviewers?' do
+ it 'returns false as issues do not support reviewers feature' do
+ stub_feature_flags(merge_request_reviewers: true)
+
+ issue = build_stubbed(:issue)
+
+ expect(issue.allows_reviewers?).to be(false)
+ end
+ end
end
diff --git a/spec/models/iteration_spec.rb b/spec/models/iteration_spec.rb
index 5c684fa9771..19a1625aad3 100644
--- a/spec/models/iteration_spec.rb
+++ b/spec/models/iteration_spec.rb
@@ -32,6 +32,59 @@ RSpec.describe Iteration do
end
end
+ describe '.filter_by_state' do
+ let_it_be(:closed_iteration) { create(:iteration, :closed, :skip_future_date_validation, group: group, start_date: 8.days.ago, due_date: 2.days.ago) }
+ let_it_be(:started_iteration) { create(:iteration, :started, :skip_future_date_validation, group: group, start_date: 1.day.ago, due_date: 6.days.from_now) }
+ let_it_be(:upcoming_iteration) { create(:iteration, :upcoming, group: group, start_date: 1.week.from_now, due_date: 2.weeks.from_now) }
+
+ shared_examples_for 'filter_by_state' do
+ it 'filters by the given state' do
+ expect(described_class.filter_by_state(Iteration.all, state)).to match(expected_iterations)
+ end
+ end
+
+ context 'filtering by closed iterations' do
+ it_behaves_like 'filter_by_state' do
+ let(:state) { 'closed' }
+ let(:expected_iterations) { [closed_iteration] }
+ end
+ end
+
+ context 'filtering by started iterations' do
+ it_behaves_like 'filter_by_state' do
+ let(:state) { 'started' }
+ let(:expected_iterations) { [started_iteration] }
+ end
+ end
+
+ context 'filtering by opened iterations' do
+ it_behaves_like 'filter_by_state' do
+ let(:state) { 'opened' }
+ let(:expected_iterations) { [started_iteration, upcoming_iteration] }
+ end
+ end
+
+ context 'filtering by upcoming iterations' do
+ it_behaves_like 'filter_by_state' do
+ let(:state) { 'upcoming' }
+ let(:expected_iterations) { [upcoming_iteration] }
+ end
+ end
+
+ context 'filtering by "all"' do
+ it_behaves_like 'filter_by_state' do
+ let(:state) { 'all' }
+ let(:expected_iterations) { [closed_iteration, started_iteration, upcoming_iteration] }
+ end
+ end
+
+ context 'filtering by nonexistent filter' do
+ it 'raises ArgumentError' do
+ expect { described_class.filter_by_state(Iteration.none, 'unknown') }.to raise_error(ArgumentError, 'Unknown state filter: unknown')
+ end
+ end
+ end
+
context 'Validations' do
subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
diff --git a/spec/models/jira_connect_installation_spec.rb b/spec/models/jira_connect_installation_spec.rb
new file mode 100644
index 00000000000..8ef96114c45
--- /dev/null
+++ b/spec/models/jira_connect_installation_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnectInstallation do
+ describe 'associations' do
+ it { is_expected.to have_many(:subscriptions).class_name('JiraConnectSubscription') }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:client_key) }
+ it { is_expected.to validate_uniqueness_of(:client_key) }
+ it { is_expected.to validate_presence_of(:shared_secret) }
+ it { is_expected.to validate_presence_of(:base_url) }
+
+ it { is_expected.to allow_value('https://test.atlassian.net').for(:base_url) }
+ it { is_expected.not_to allow_value('not/a/url').for(:base_url) }
+ end
+
+ describe '.for_project' do
+ let(:other_group) { create(:group) }
+ let(:parent_group) { create(:group) }
+ let(:group) { create(:group, parent: parent_group) }
+ let(:project) { create(:project, group: group) }
+
+ subject { described_class.for_project(project) }
+
+ it 'returns installations with subscriptions for project' do
+ sub_on_project_namespace = create(:jira_connect_subscription, namespace: group)
+ sub_on_ancestor_namespace = create(:jira_connect_subscription, namespace: parent_group)
+
+ # Subscription on other group that shouldn't be returned
+ create(:jira_connect_subscription, namespace: other_group)
+
+ expect(subject).to contain_exactly(sub_on_project_namespace.installation, sub_on_ancestor_namespace.installation)
+ end
+
+ it 'returns distinct installations' do
+ subscription = create(:jira_connect_subscription, namespace: group)
+ create(:jira_connect_subscription, namespace: parent_group, installation: subscription.installation)
+
+ expect(subject).to contain_exactly(subscription.installation)
+ end
+ end
+end
diff --git a/spec/models/jira_connect_subscription_spec.rb b/spec/models/jira_connect_subscription_spec.rb
new file mode 100644
index 00000000000..548c030f4c4
--- /dev/null
+++ b/spec/models/jira_connect_subscription_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnectSubscription do
+ describe 'associations' do
+ it { is_expected.to belong_to(:installation).class_name('JiraConnectInstallation') }
+ it { is_expected.to belong_to(:namespace).class_name('Namespace') }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:installation) }
+ it { is_expected.to validate_presence_of(:namespace) }
+ end
+end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 52f47f0a211..39807747cc0 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -16,14 +16,14 @@ RSpec.describe Member do
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:source) }
- it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.all_values) }
it_behaves_like 'an object with email-formated attributes', :invite_email do
subject { build(:project_member) }
end
context "when an invite email is provided" do
- let(:member) { build(:project_member, invite_email: "user@example.com", user: nil) }
+ let_it_be(:project) { create(:project) }
+ let(:member) { build(:project_member, source: project, invite_email: "user@example.com", user: nil) }
it "doesn't require a user" do
expect(member).to be_valid
@@ -149,6 +149,7 @@ RSpec.describe Member do
accepted_request_user = create(:user).tap { |u| project.request_access(u) }
@accepted_request_member = project.requesters.find_by(user_id: accepted_request_user.id).tap { |m| m.accept_request }
+ @member_with_minimal_access = create(:group_member, :minimal_access, source: group)
end
describe '.access_for_user_ids' do
@@ -179,6 +180,15 @@ RSpec.describe Member do
it { expect(described_class.non_invite).to include @accepted_request_member }
end
+ describe '.non_minimal_access' do
+ it { expect(described_class.non_minimal_access).to include @maintainer }
+ it { expect(described_class.non_minimal_access).to include @invited_member }
+ it { expect(described_class.non_minimal_access).to include @accepted_invite_member }
+ it { expect(described_class.non_minimal_access).to include @requested_member }
+ it { expect(described_class.non_minimal_access).to include @accepted_request_member }
+ it { expect(described_class.non_minimal_access).not_to include @member_with_minimal_access }
+ end
+
describe '.request' do
it { expect(described_class.request).not_to include @maintainer }
it { expect(described_class.request).not_to include @invited_member }
@@ -256,6 +266,34 @@ RSpec.describe Member do
it { is_expected.not_to include @blocked_maintainer }
it { is_expected.not_to include @blocked_developer }
end
+
+ describe '.active' do
+ subject { described_class.active.to_a }
+
+ it { is_expected.to include @owner }
+ it { is_expected.to include @maintainer }
+ it { is_expected.to include @invited_member }
+ it { is_expected.to include @accepted_invite_member }
+ it { is_expected.not_to include @requested_member }
+ it { is_expected.to include @accepted_request_member }
+ it { is_expected.not_to include @blocked_maintainer }
+ it { is_expected.not_to include @blocked_developer }
+ it { is_expected.not_to include @member_with_minimal_access }
+ end
+
+ describe '.active_without_invites_and_requests' do
+ subject { described_class.active_without_invites_and_requests.to_a }
+
+ it { is_expected.to include @owner }
+ it { is_expected.to include @maintainer }
+ it { is_expected.not_to include @invited_member }
+ it { is_expected.to include @accepted_invite_member }
+ it { is_expected.not_to include @requested_member }
+ it { is_expected.to include @accepted_request_member }
+ it { is_expected.not_to include @blocked_maintainer }
+ it { is_expected.not_to include @blocked_developer }
+ it { is_expected.not_to include @member_with_minimal_access }
+ end
end
describe "Delegate methods" do
@@ -630,6 +668,32 @@ RSpec.describe Member do
end
end
+ describe '.find_by_invite_token' do
+ let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) }
+
+ it 'finds the member' do
+ expect(described_class.find_by_invite_token(member.raw_invite_token)).to eq member
+ end
+ end
+
+ describe "#invite_to_unknown_user?" do
+ subject { member.invite_to_unknown_user? }
+
+ let(:member) { create(:project_member, invite_email: "user@example.com", invite_token: '1234', user: user) }
+
+ context 'when user is nil' do
+ let(:user) { nil }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when user is set' do
+ let(:user) { build(:user) }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
describe "destroying a record", :delete do
it "refreshes user's authorized projects" do
project = create(:project, :private)
@@ -655,7 +719,7 @@ RSpec.describe Member do
describe 'create member' do
let!(:source) { create(source_type) }
- subject { create(member_type, :guest, user: user, source_type => source) }
+ subject { create(member_type, :guest, user: user, source: source) }
include_examples 'update highest role with exclusive lease'
end
diff --git a/spec/models/merge_request/metrics_spec.rb b/spec/models/merge_request/metrics_spec.rb
index 82402b95597..760eaf1ac7f 100644
--- a/spec/models/merge_request/metrics_spec.rb
+++ b/spec/models/merge_request/metrics_spec.rb
@@ -9,17 +9,6 @@ RSpec.describe MergeRequest::Metrics do
it { is_expected.to belong_to(:merged_by).class_name('User') }
end
- it 'sets `target_project_id` before save' do
- merge_request = create(:merge_request)
- metrics = merge_request.metrics
-
- metrics.update_column(:target_project_id, nil)
-
- metrics.save!
-
- expect(metrics.target_project_id).to eq(merge_request.target_project_id)
- end
-
describe 'scopes' do
let_it_be(:metrics_1) { create(:merge_request).metrics.tap { |m| m.update!(merged_at: 10.days.ago) } }
let_it_be(:metrics_2) { create(:merge_request).metrics.tap { |m| m.update!(merged_at: 5.days.ago) } }
diff --git a/spec/models/merge_request_diff_commit_spec.rb b/spec/models/merge_request_diff_commit_spec.rb
index 84fdfae1ed1..2edf44ecdc4 100644
--- a/spec/models/merge_request_diff_commit_spec.rb
+++ b/spec/models/merge_request_diff_commit_spec.rb
@@ -7,7 +7,12 @@ RSpec.describe MergeRequestDiffCommit do
let(:project) { merge_request.project }
it_behaves_like 'a BulkInsertSafe model', MergeRequestDiffCommit do
- let(:valid_items_for_bulk_insertion) { build_list(:merge_request_diff_commit, 10) }
+ let(:valid_items_for_bulk_insertion) do
+ build_list(:merge_request_diff_commit, 10) do |mr_diff_commit|
+ mr_diff_commit.merge_request_diff = create(:merge_request_diff)
+ end
+ end
+
let(:invalid_items_for_bulk_insertion) { [] } # class does not have any validations defined
end
diff --git a/spec/models/merge_request_diff_file_spec.rb b/spec/models/merge_request_diff_file_spec.rb
index 25971f63338..5a48438adab 100644
--- a/spec/models/merge_request_diff_file_spec.rb
+++ b/spec/models/merge_request_diff_file_spec.rb
@@ -4,7 +4,12 @@ require 'spec_helper'
RSpec.describe MergeRequestDiffFile do
it_behaves_like 'a BulkInsertSafe model', MergeRequestDiffFile do
- let(:valid_items_for_bulk_insertion) { build_list(:merge_request_diff_file, 10) }
+ let(:valid_items_for_bulk_insertion) do
+ build_list(:merge_request_diff_file, 10) do |mr_diff_file|
+ mr_diff_file.merge_request_diff = create(:merge_request_diff)
+ end
+ end
+
let(:invalid_items_for_bulk_insertion) { [] } # class does not have any validations defined
end
@@ -25,6 +30,14 @@ RSpec.describe MergeRequestDiffFile do
it 'unpacks from base 64' do
expect(subject.diff).to eq(unpacked)
end
+
+ context 'invalid base64' do
+ let(:packed) { '---/dev/null' }
+
+ it 'returns the raw diff' do
+ expect(subject.diff).to eq(packed)
+ end
+ end
end
context 'when the diff is not marked as binary' do
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index e02c71a1c6f..2c64201e84d 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -293,6 +293,7 @@ RSpec.describe MergeRequestDiff do
it 'does nothing with an empty diff' do
stub_external_diffs_setting(enabled: true)
MergeRequestDiffFile.where(merge_request_diff_id: diff.id).delete_all
+ diff.save! # update files_count
expect(diff).not_to receive(:update!)
@@ -675,8 +676,39 @@ RSpec.describe MergeRequestDiff do
end
describe '#files_count' do
- it 'returns number of diff files' do
- expect(diff_with_commits.files_count).to eq(diff_with_commits.merge_request_diff_files.count)
+ let_it_be(:merge_request) { create(:merge_request) }
+
+ let(:diff) { merge_request.merge_request_diff }
+ let(:actual_count) { diff.merge_request_diff_files.count }
+
+ it 'is set by default' do
+ expect(diff.read_attribute(:files_count)).to eq(actual_count)
+ end
+
+ it 'is set to the sentinel value if the actual value exceeds it' do
+ stub_const("#{described_class}::FILES_COUNT_SENTINEL", actual_count - 1)
+
+ diff.save! # update the files_count column with the stub in place
+
+ expect(diff.read_attribute(:files_count)).to eq(described_class::FILES_COUNT_SENTINEL)
+ end
+
+ it 'uses the cached count if present' do
+ diff.update_columns(files_count: actual_count + 1)
+
+ expect(diff.files_count).to eq(actual_count + 1)
+ end
+
+ it 'uses the actual count if nil' do
+ diff.update_columns(files_count: nil)
+
+ expect(diff.files_count).to eq(actual_count)
+ end
+
+ it 'uses the actual count if overflown' do
+ diff.update_columns(files_count: described_class::FILES_COUNT_SENTINEL)
+
+ expect(diff.files_count).to eq(actual_count)
end
end
diff --git a/spec/models/merge_request_reviewer_spec.rb b/spec/models/merge_request_reviewer_spec.rb
new file mode 100644
index 00000000000..55199cd96ad
--- /dev/null
+++ b/spec/models/merge_request_reviewer_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequestReviewer do
+ let(:merge_request) { create(:merge_request) }
+
+ subject { merge_request.merge_request_reviewers.build(reviewer: create(:user)) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:merge_request).class_name('MergeRequest') }
+ it { is_expected.to belong_to(:reviewer).class_name('User') }
+ end
+end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 6edef54b153..98f709a0610 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -2,13 +2,16 @@
require 'spec_helper'
-RSpec.describe MergeRequest do
+RSpec.describe MergeRequest, factory_default: :keep do
include RepoHelpers
include ProjectForksHelper
include ReactiveCachingHelpers
using RSpec::Parameterized::TableSyntax
+ let_it_be(:namespace) { create_default(:namespace) }
+ let_it_be(:project, refind: true) { create_default(:project, :repository) }
+
subject { create(:merge_request) }
describe 'associations' do
@@ -18,6 +21,7 @@ RSpec.describe MergeRequest do
it { is_expected.to belong_to(:source_project).class_name('Project') }
it { is_expected.to belong_to(:merge_user).class_name("User") }
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) }
@@ -57,6 +61,24 @@ RSpec.describe MergeRequest do
end
end
+ describe '.order_merged_at_asc' do
+ let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) }
+ let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) }
+
+ it 'returns MRs ordered by merged_at ascending' do
+ expect(described_class.order_merged_at_asc).to eq([older_mr, newer_mr])
+ end
+ end
+
+ describe '.order_merged_at_desc' do
+ let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) }
+ let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) }
+
+ it 'returns MRs ordered by merged_at descending' do
+ expect(described_class.order_merged_at_desc).to eq([newer_mr, older_mr])
+ end
+ end
+
describe '#squash_in_progress?' do
let(:repo_path) do
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
@@ -282,18 +304,6 @@ RSpec.describe MergeRequest do
expect(merge_request.target_project_id).to eq(project.id)
expect(merge_request.target_project_id).to eq(merge_request.metrics.target_project_id)
end
-
- context 'when metrics record already exists with NULL target_project_id' do
- before do
- merge_request.metrics.update_column(:target_project_id, nil)
- end
-
- it 'returns the metrics record' do
- metrics_record = merge_request.ensure_metrics
-
- expect(metrics_record).to be_persisted
- end
- end
end
end
@@ -359,7 +369,7 @@ RSpec.describe MergeRequest do
it 'returns merge requests that match the given merge commit' do
note = create(:track_mr_picking_note, commit_id: '456abc')
- create(:track_mr_picking_note, commit_id: '456def')
+ create(:track_mr_picking_note, project: create(:project), commit_id: '456def')
expect(described_class.by_cherry_pick_sha('456abc')).to eq([note.noteable])
end
@@ -427,6 +437,23 @@ RSpec.describe MergeRequest do
end
end
+ describe '.sort_by_attribute' do
+ context 'merged_at' do
+ let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) }
+ let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) }
+
+ it 'sorts asc' do
+ merge_requests = described_class.sort_by_attribute(:merged_at_asc)
+ expect(merge_requests).to eq([older_mr, newer_mr])
+ end
+
+ it 'sorts desc' do
+ merge_requests = described_class.sort_by_attribute(:merged_at_desc)
+ expect(merge_requests).to eq([newer_mr, older_mr])
+ end
+ end
+ end
+
describe '#target_branch_sha' do
let(:project) { create(:project, :repository) }
@@ -831,7 +858,7 @@ RSpec.describe MergeRequest do
end
context 'with commit diff note' do
- let(:other_merge_request) { create(:merge_request) }
+ let(:other_merge_request) { create(:merge_request, source_project: create(:project, :repository)) }
let!(:diff_note) do
create(:diff_note_on_commit, project: merge_request.project)
@@ -854,8 +881,10 @@ RSpec.describe MergeRequest do
end
describe '#diff_size' do
+ let_it_be(:project) { create(:project, :repository) }
+
let(:merge_request) do
- build(:merge_request, source_branch: 'expand-collapse-files', target_branch: 'master')
+ build(:merge_request, source_project: project, source_branch: 'expand-collapse-files', target_branch: 'master')
end
context 'when there are MR diffs' do
@@ -1030,6 +1059,8 @@ RSpec.describe MergeRequest do
end
describe '#closes_issues' do
+ let(:project) { create(:project) }
+
let(:issue0) { create :issue, project: subject.project }
let(:issue1) { create :issue, project: subject.project }
@@ -1037,6 +1068,8 @@ RSpec.describe MergeRequest do
let(:commit1) { double('commit1', safe_message: "Fixes #{issue0.to_reference}") }
let(:commit2) { double('commit2', safe_message: "Fixes #{issue1.to_reference}") }
+ subject { create(:merge_request, source_project: project) }
+
before do
subject.project.add_developer(subject.author)
allow(subject).to receive(:commits).and_return([commit0, commit1, commit2])
@@ -1087,6 +1120,8 @@ RSpec.describe MergeRequest do
end
context 'when the project has an external issue tracker' do
+ subject { create(:merge_request, source_project: create(:project, :repository)) }
+
before do
subject.project.add_developer(subject.author)
commit = double(:commit, safe_message: 'Fixes TEST-3')
@@ -1253,14 +1288,13 @@ RSpec.describe MergeRequest do
end
describe "#source_branch_exists?" do
- let(:merge_request) { subject }
+ let(:project) { create(:project, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
let(:repository) { merge_request.source_project.repository }
context 'when the source project is set' do
- it 'memoizes the value and returns the result' do
- expect(repository).to receive(:branch_exists?).once.with(merge_request.source_branch).and_return(true)
-
- 2.times { expect(merge_request.source_branch_exists?).to eq(true) }
+ it 'returns true when the branch exists' do
+ expect(merge_request.source_branch_exists?).to eq(true)
end
end
@@ -1480,7 +1514,9 @@ RSpec.describe MergeRequest do
end
context 'new merge request' do
- subject { build(:merge_request) }
+ let_it_be(:project) { create(:project, :repository) }
+
+ subject { build(:merge_request, source_project: project) }
context 'compare commits' do
before do
@@ -1575,11 +1611,36 @@ RSpec.describe MergeRequest do
before do
subject.mark_as_merged!
- subject.update_attribute(:merge_commit_sha, pipeline.sha)
end
- it 'returns the post-merge pipeline' do
- expect(subject.merge_pipeline).to eq(pipeline)
+ context 'and there is a merge commit' do
+ before do
+ subject.update_attribute(:merge_commit_sha, pipeline.sha)
+ end
+
+ it 'returns the pipeline associated with that merge request' do
+ expect(subject.merge_pipeline).to eq(pipeline)
+ end
+ end
+
+ context 'and there is no merge commit, but there is a diff head' do
+ before do
+ allow(subject).to receive(:diff_head_sha).and_return(pipeline.sha)
+ end
+
+ it 'returns the pipeline associated with that merge request' do
+ expect(subject.merge_pipeline).to eq(pipeline)
+ end
+ end
+
+ context 'and there is no merge commit, but there is a squash commit' do
+ before do
+ subject.update_attribute(:squash_commit_sha, pipeline.sha)
+ end
+
+ it 'returns the pipeline associated with that merge request' do
+ expect(subject.merge_pipeline).to eq(pipeline)
+ end
end
end
end
@@ -1706,16 +1767,14 @@ RSpec.describe MergeRequest do
describe '#has_test_reports?' do
subject { merge_request.has_test_reports? }
- let(:project) { create(:project, :repository) }
-
context 'when head pipeline has test reports' do
- let(:merge_request) { create(:merge_request, :with_test_reports, source_project: project) }
+ let(:merge_request) { create(:merge_request, :with_test_reports) }
it { is_expected.to be_truthy }
end
context 'when head pipeline does not have test reports' do
- let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:merge_request) { create(:merge_request) }
it { is_expected.to be_falsey }
end
@@ -1724,16 +1783,14 @@ RSpec.describe MergeRequest do
describe '#has_accessibility_reports?' do
subject { merge_request.has_accessibility_reports? }
- let(:project) { create(:project, :repository) }
-
context 'when head pipeline has an accessibility reports' do
- let(:merge_request) { create(:merge_request, :with_accessibility_reports, source_project: project) }
+ let(:merge_request) { create(:merge_request, :with_accessibility_reports) }
it { is_expected.to be_truthy }
end
context 'when head pipeline does not have accessibility reports' do
- let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:merge_request) { create(:merge_request) }
it { is_expected.to be_falsey }
end
@@ -1742,27 +1799,23 @@ RSpec.describe MergeRequest do
describe '#has_coverage_reports?' do
subject { merge_request.has_coverage_reports? }
- let(:project) { create(:project, :repository) }
-
context 'when head pipeline has coverage reports' do
- let(:merge_request) { create(:merge_request, :with_coverage_reports, source_project: project) }
+ let(:merge_request) { create(:merge_request, :with_coverage_reports) }
it { is_expected.to be_truthy }
end
context 'when head pipeline does not have coverage reports' do
- let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:merge_request) { create(:merge_request) }
it { is_expected.to be_falsey }
end
end
describe '#has_terraform_reports?' do
- let_it_be(:project) { create(:project, :repository) }
-
context 'when head pipeline has terraform reports' do
it 'returns true' do
- merge_request = create(:merge_request, :with_terraform_reports, source_project: project)
+ merge_request = create(:merge_request, :with_terraform_reports)
expect(merge_request.has_terraform_reports?).to be_truthy
end
@@ -1770,7 +1823,7 @@ RSpec.describe MergeRequest do
context 'when head pipeline does not have terraform reports' do
it 'returns false' do
- merge_request = create(:merge_request, source_project: project)
+ merge_request = create(:merge_request)
expect(merge_request.has_terraform_reports?).to be_falsey
end
@@ -1778,8 +1831,7 @@ RSpec.describe MergeRequest do
end
describe '#calculate_reactive_cache' do
- let(:project) { create(:project, :repository) }
- let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:merge_request) { create(:merge_request) }
subject { merge_request.calculate_reactive_cache(service_class_name) }
@@ -1867,12 +1919,6 @@ RSpec.describe MergeRequest do
subject { merge_request.find_coverage_reports }
context 'when head pipeline has coverage reports' do
- let!(:job) do
- create(:ci_build, options: { artifacts: { reports: { cobertura: ['cobertura-coverage.xml'] } } }, pipeline: pipeline)
- end
-
- let!(:artifacts_metadata) { create(:ci_job_artifact, :metadata, job: job) }
-
context 'when reactive cache worker is parsing results asynchronously' do
it 'returns status' do
expect(subject[:status]).to eq(:parsing)
@@ -2074,11 +2120,13 @@ RSpec.describe MergeRequest do
end
context 'when merge request is not persisted' do
+ let_it_be(:project) { create(:project, :repository) }
+
context 'when compare commits are set in the service' do
let(:commit) { spy('commit') }
subject do
- build(:merge_request, compare_commits: [commit, commit])
+ build(:merge_request, source_project: project, compare_commits: [commit, commit])
end
it 'returns commits from compare commits temporary data' do
@@ -2087,7 +2135,7 @@ RSpec.describe MergeRequest do
end
context 'when compare commits are not set in the service' do
- subject { build(:merge_request) }
+ subject { build(:merge_request, source_project: project) }
it 'returns array with diff head sha element only' do
expect(subject.all_commit_shas).to eq [subject.diff_head_sha]
@@ -2112,7 +2160,63 @@ RSpec.describe MergeRequest do
end
end
+ describe '#merged_commit_sha' do
+ it 'returns nil when not merged' do
+ expect(subject.merged_commit_sha).to be_nil
+ end
+
+ context 'when the MR is merged' do
+ let(:sha) { 'f7ce827c314c9340b075657fd61c789fb01cf74d' }
+
+ before do
+ subject.mark_as_merged!
+ end
+
+ it 'returns merge_commit_sha when there is a merge_commit_sha' do
+ subject.update_attribute(:merge_commit_sha, sha)
+
+ expect(subject.merged_commit_sha).to eq(sha)
+ end
+
+ it 'returns squash_commit_sha when there is a squash_commit_sha' do
+ subject.update_attribute(:squash_commit_sha, sha)
+
+ expect(subject.merged_commit_sha).to eq(sha)
+ end
+
+ it 'returns diff_head_sha when there are no merge_commit_sha and squash_commit_sha' do
+ allow(subject).to receive(:diff_head_sha).and_return(sha)
+
+ expect(subject.merged_commit_sha).to eq(sha)
+ end
+ end
+ end
+
+ describe '#short_merged_commit_sha' do
+ context 'when merged_commit_sha is nil' do
+ before do
+ allow(subject).to receive(:merged_commit_sha).and_return(nil)
+ end
+
+ it 'returns nil' do
+ expect(subject.short_merged_commit_sha).to be_nil
+ end
+ end
+
+ context 'when merged_commit_sha is present' do
+ before do
+ allow(subject).to receive(:merged_commit_sha).and_return('f7ce827c314c9340b075657fd61c789fb01cf74d')
+ end
+
+ it 'returns shortened merged_commit_sha' do
+ expect(subject.short_merged_commit_sha).to eq('f7ce827c')
+ end
+ end
+ end
+
describe '#can_be_reverted?' do
+ subject { create(:merge_request, source_project: create(:project, :repository)) }
+
context 'when there is no merge_commit for the MR' do
before do
subject.metrics.update!(merged_at: Time.current.utc)
@@ -2301,8 +2405,6 @@ RSpec.describe MergeRequest do
end
describe '#participants' do
- let(:project) { create(:project, :public) }
-
let(:mr) do
create(:merge_request, source_project: project, target_project: project)
end
@@ -2410,9 +2512,7 @@ RSpec.describe MergeRequest do
end
describe '#mergeable?' do
- let(:project) { create(:project) }
-
- subject { create(:merge_request, source_project: project) }
+ subject { build_stubbed(:merge_request) }
it 'returns false if #mergeable_state? is false' do
expect(subject).to receive(:mergeable_state?) { false }
@@ -2427,6 +2527,57 @@ RSpec.describe MergeRequest do
expect(subject.mergeable?).to be_truthy
end
+
+ context 'with skip_ci_check option' do
+ using RSpec::Parameterized::TableSyntax
+
+ before do
+ allow(subject).to receive_messages(check_mergeability: nil,
+ can_be_merged?: true,
+ broken?: false)
+ end
+
+ where(:mergeable_ci_state, :skip_ci_check, :expected_mergeable) do
+ false | false | false
+ false | true | true
+ true | false | true
+ true | true | true
+ end
+
+ with_them do
+ it 'overrides mergeable_ci_state?' do
+ allow(subject).to receive(:mergeable_ci_state?) { mergeable_ci_state }
+
+ expect(subject.mergeable?(skip_ci_check: skip_ci_check)).to eq(expected_mergeable)
+ end
+ end
+ end
+
+ context 'with skip_discussions_check option' do
+ using RSpec::Parameterized::TableSyntax
+
+ before do
+ allow(subject).to receive_messages(mergeable_ci_state?: true,
+ check_mergeability: nil,
+ can_be_merged?: true,
+ broken?: false)
+ end
+
+ where(:mergeable_discussions_state, :skip_discussions_check, :expected_mergeable) do
+ false | false | false
+ false | true | true
+ true | false | true
+ true | true | true
+ end
+
+ with_them do
+ it 'overrides mergeable_discussions_state?' do
+ allow(subject).to receive(:mergeable_discussions_state?) { mergeable_discussions_state }
+
+ expect(subject.mergeable?(skip_discussions_check: skip_discussions_check)).to eq(expected_mergeable)
+ end
+ end
+ end
end
describe '#check_mergeability' do
@@ -2482,9 +2633,7 @@ RSpec.describe MergeRequest do
end
describe '#mergeable_state?' do
- let(:project) { create(:project, :repository) }
-
- subject { create(:merge_request, source_project: project) }
+ subject { create(:merge_request) }
it 'checks if merge request can be merged' do
allow(subject).to receive(:mergeable_ci_state?) { true }
@@ -2532,6 +2681,10 @@ RSpec.describe MergeRequest do
it 'returns false' do
expect(subject.mergeable_state?).to be_falsey
end
+
+ it 'returns true when skipping ci check' do
+ expect(subject.mergeable_state?(skip_ci_check: true)).to be(true)
+ end
end
context 'when #mergeable_discussions_state? is false' do
@@ -2599,9 +2752,9 @@ RSpec.describe MergeRequest do
let(:pipeline) { create(:ci_empty_pipeline) }
context 'when it is only allowed to merge when build is green' do
- let(:project) { create(:project, only_allow_merge_if_pipeline_succeeds: true) }
+ let_it_be(:project) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true) }
- subject { build(:merge_request, target_project: project) }
+ subject { build(:merge_request, source_project: project) }
context 'and a failed pipeline is associated' do
before do
@@ -2640,9 +2793,9 @@ RSpec.describe MergeRequest do
end
context 'when it is only allowed to merge when build is green or skipped' do
- let(:project) { create(:project, only_allow_merge_if_pipeline_succeeds: true, allow_merge_on_skipped_pipeline: true) }
+ let_it_be(:project) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true, allow_merge_on_skipped_pipeline: true) }
- subject { build(:merge_request, target_project: project) }
+ subject { build(:merge_request, source_project: project) }
context 'and a failed pipeline is associated' do
before do
@@ -2681,9 +2834,9 @@ RSpec.describe MergeRequest do
end
context 'when merges are not restricted to green builds' do
- let(:project) { create(:project, only_allow_merge_if_pipeline_succeeds: false) }
+ let_it_be(:project) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: false) }
- subject { build(:merge_request, target_project: project) }
+ subject { build(:merge_request, source_project: project) }
context 'and a failed pipeline is associated' do
before do
@@ -2725,7 +2878,7 @@ RSpec.describe MergeRequest do
let(:merge_request) { create(:merge_request_with_diff_notes, source_project: project) }
context 'when project.only_allow_merge_if_all_discussions_are_resolved == true' do
- let(:project) { create(:project, :repository, only_allow_merge_if_all_discussions_are_resolved: true) }
+ let_it_be(:project) { create(:project, :repository, only_allow_merge_if_all_discussions_are_resolved: true) }
context 'with all discussions resolved' do
before do
@@ -2974,6 +3127,10 @@ RSpec.describe MergeRequest do
end
describe '#branch_merge_base_commit' do
+ let(:project) { create(:project, :repository) }
+
+ subject { create(:merge_request, :with_diffs, source_project: project) }
+
context 'source and target branch exist' do
it { expect(subject.branch_merge_base_commit.sha).to eq('ae73cb07c9eeaf35924a10f713b364d32b2dd34f') }
it { expect(subject.branch_merge_base_commit).to be_a(Commit) }
@@ -2993,7 +3150,9 @@ RSpec.describe MergeRequest do
describe "#diff_refs" do
context "with diffs" do
- subject { create(:merge_request, :with_diffs) }
+ let(:project) { create(:project, :repository) }
+
+ subject { create(:merge_request, :with_diffs, source_project: project) }
let(:expected_diff_refs) do
Gitlab::Diff::DiffRefs.new(
@@ -3195,7 +3354,8 @@ RSpec.describe MergeRequest do
pipeline
end
- let(:project) { create(:project, :public, :repository, only_allow_merge_if_pipeline_succeeds: true) }
+ let_it_be(:project) { create(:project, :public, :repository, only_allow_merge_if_pipeline_succeeds: true) }
+
let(:developer) { create(:user) }
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request, source_project: project) }
@@ -3289,8 +3449,7 @@ RSpec.describe MergeRequest do
end
describe '#pipeline_coverage_delta' do
- let!(:project) { create(:project, :repository) }
- let!(:merge_request) { create(:merge_request, source_project: project) }
+ let!(:merge_request) { create(:merge_request) }
let!(:source_pipeline) do
create(:ci_pipeline,
@@ -3396,7 +3555,9 @@ RSpec.describe MergeRequest do
end
describe '#merge_request_diff_for' do
- subject { create(:merge_request, importing: true) }
+ let(:project) { create(:project, :repository) }
+
+ subject { create(:merge_request, importing: true, source_project: project) }
let!(:merge_request_diff1) { subject.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
let!(:merge_request_diff2) { subject.merge_request_diffs.create(head_commit_sha: nil) }
@@ -3427,9 +3588,10 @@ RSpec.describe MergeRequest do
end
describe '#version_params_for' do
- subject { create(:merge_request, importing: true) }
+ let(:project) { create(:project, :repository) }
+
+ subject { create(:merge_request, importing: true, source_project: project) }
- let(:project) { subject.project }
let!(:merge_request_diff1) { subject.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
let!(:merge_request_diff2) { subject.merge_request_diffs.create(head_commit_sha: nil) }
let!(:merge_request_diff3) { subject.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
@@ -3460,6 +3622,10 @@ RSpec.describe MergeRequest do
end
describe '#fetch_ref!' do
+ let(:project) { create(:project, :repository) }
+
+ subject { create(:merge_request, :with_diffs, source_project: project) }
+
it 'fetches the ref correctly' do
expect { subject.target_project.repository.delete_refs(subject.ref_path) }.not_to raise_error
@@ -3482,8 +3648,10 @@ RSpec.describe MergeRequest do
end
context 'state machine transitions' do
+ let(:project) { create(:project, :repository) }
+
describe '#unlock_mr' do
- subject { create(:merge_request, state: 'locked', merge_jid: 123) }
+ subject { create(:merge_request, state: 'locked', source_project: project, merge_jid: 123) }
it 'updates merge request head pipeline and sets merge_jid to nil', :sidekiq_might_not_need_inline do
pipeline = create(:ci_empty_pipeline, project: subject.project, ref: subject.source_branch, sha: subject.source_branch_sha)
@@ -3500,7 +3668,7 @@ RSpec.describe MergeRequest do
let(:notification_service) { double(:notification_service) }
let(:todo_service) { double(:todo_service) }
- subject { create(:merge_request, state, merge_status: :unchecked) }
+ subject { create(:merge_request, state, source_project: project, merge_status: :unchecked) }
before do
allow(NotificationService).to receive(:new).and_return(notification_service)
@@ -3589,7 +3757,7 @@ RSpec.describe MergeRequest do
end
context 'source branch is missing' do
- subject { create(:merge_request, :invalid, :opened, merge_status: :unchecked, target_branch: 'master') }
+ subject { create(:merge_request, :invalid, :opened, source_project: project, merge_status: :unchecked, target_branch: 'master') }
before do
allow(subject.project.repository).to receive(:can_be_merged?).and_call_original
@@ -3622,10 +3790,8 @@ RSpec.describe MergeRequest do
end
describe '#should_be_rebased?' do
- let(:project) { create(:project, :repository) }
-
it 'returns false for the same source and target branches' do
- merge_request = create(:merge_request, source_project: project, target_project: project)
+ merge_request = build_stubbed(:merge_request, source_project: project, target_project: project)
expect(merge_request.should_be_rebased?).to be_falsey
end
@@ -3640,7 +3806,7 @@ RSpec.describe MergeRequest do
end
with_them do
- let(:merge_request) { create(:merge_request) }
+ let(:merge_request) { build_stubbed(:merge_request) }
subject { merge_request.rebase_in_progress? }
@@ -3863,7 +4029,7 @@ RSpec.describe MergeRequest do
describe '#cleanup_refs' do
subject { merge_request.cleanup_refs(only: only) }
- let(:merge_request) { build(:merge_request) }
+ let(:merge_request) { build(:merge_request, source_project: create(:project, :repository)) }
context 'when removing all refs' do
let(:only) { :all }
@@ -4084,4 +4250,58 @@ RSpec.describe MergeRequest do
expect(context[:label_url_method]).to eq(:project_merge_requests_url)
end
end
+
+ describe '#head_pipeline_builds_with_coverage' do
+ it 'delegates to head_pipeline' do
+ expect(subject)
+ .to delegate_method(:builds_with_coverage)
+ .to(:head_pipeline)
+ .with_prefix
+ .with_arguments(allow_nil: true)
+ end
+ end
+
+ describe '#allows_reviewers?' do
+ it 'returns false without merge_request_reviewers feature' do
+ stub_feature_flags(merge_request_reviewers: false)
+
+ merge_request = build_stubbed(:merge_request)
+
+ expect(merge_request.allows_reviewers?).to be(false)
+ end
+
+ it 'returns true with merge_request_reviewers feature' do
+ stub_feature_flags(merge_request_reviewers: true)
+
+ merge_request = build_stubbed(:merge_request)
+
+ expect(merge_request.allows_reviewers?).to be(true)
+ end
+ end
+
+ describe '#merge_ref_head' do
+ let(:merge_request) { create(:merge_request) }
+
+ context 'when merge_ref_sha is not present' do
+ let!(:result) do
+ MergeRequests::MergeToRefService
+ .new(merge_request.project, merge_request.author)
+ .execute(merge_request)
+ end
+
+ it 'returns the commit based on merge ref path' do
+ expect(merge_request.merge_ref_head.id).to eq(result[:commit_id])
+ end
+ end
+
+ context 'when merge_ref_sha is present' do
+ before do
+ merge_request.update!(merge_ref_sha: merge_request.project.repository.commit.id)
+ end
+
+ it 'returns the commit based on cached merge_ref_sha' do
+ expect(merge_request.merge_ref_head.id).to eq(merge_request.merge_ref_sha)
+ end
+ end
+ end
end
diff --git a/spec/models/metrics/dashboard/annotation_spec.rb b/spec/models/metrics/dashboard/annotation_spec.rb
index bd4baeb8851..4b7492016f3 100644
--- a/spec/models/metrics/dashboard/annotation_spec.rb
+++ b/spec/models/metrics/dashboard/annotation_spec.rb
@@ -100,7 +100,7 @@ RSpec.describe Metrics::Dashboard::Annotation do
describe '#ending_before' do
it 'returns annotations only for appointed dashboard' do
- Timecop.freeze do
+ freeze_time do
twelve_minutes_old_annotation = create(:metrics_dashboard_annotation, starting_at: 15.minutes.ago, ending_at: 12.minutes.ago)
create(:metrics_dashboard_annotation, starting_at: 15.minutes.ago, ending_at: 11.minutes.ago)
diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb
index b52b035e130..e611484f5ee 100644
--- a/spec/models/milestone_spec.rb
+++ b/spec/models/milestone_spec.rb
@@ -3,6 +3,11 @@
require 'spec_helper'
RSpec.describe Milestone do
+ let(:user) { create(:user) }
+ let(:issue) { create(:issue, project: project) }
+ let(:milestone) { create(:milestone, project: project) }
+ let(:project) { create(:project, :public) }
+
it_behaves_like 'a timebox', :milestone
describe 'MilestoneStruct#serializable_hash' do
@@ -47,11 +52,6 @@ RSpec.describe Milestone do
it { is_expected.to have_many(:milestone_releases) }
end
- let(:project) { create(:project, :public) }
- let(:milestone) { create(:milestone, project: project) }
- let(:issue) { create(:issue, project: project) }
- let(:user) { create(:user) }
-
describe '.predefined_id?' do
it 'returns true for a predefined Milestone ID' do
expect(Milestone.predefined_id?(described_class::Upcoming.id)).to be true
diff --git a/spec/models/namespace/root_storage_statistics_spec.rb b/spec/models/namespace/root_storage_statistics_spec.rb
index ce6f875ee09..92a8d17a2a8 100644
--- a/spec/models/namespace/root_storage_statistics_spec.rb
+++ b/spec/models/namespace/root_storage_statistics_spec.rb
@@ -44,6 +44,7 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
total_packages_size = stat1.packages_size + stat2.packages_size
total_storage_size = stat1.storage_size + stat2.storage_size
total_snippets_size = stat1.snippets_size + stat2.snippets_size
+ total_pipeline_artifacts_size = stat1.pipeline_artifacts_size + stat2.pipeline_artifacts_size
expect(root_storage_statistics.repository_size).to eq(total_repository_size)
expect(root_storage_statistics.wiki_size).to eq(total_wiki_size)
@@ -52,6 +53,7 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
expect(root_storage_statistics.packages_size).to eq(total_packages_size)
expect(root_storage_statistics.storage_size).to eq(total_storage_size)
expect(root_storage_statistics.snippets_size).to eq(total_snippets_size)
+ expect(root_storage_statistics.pipeline_artifacts_size).to eq(total_pipeline_artifacts_size)
end
it 'works when there are no projects' do
@@ -67,6 +69,7 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
expect(root_storage_statistics.packages_size).to eq(0)
expect(root_storage_statistics.storage_size).to eq(0)
expect(root_storage_statistics.snippets_size).to eq(0)
+ expect(root_storage_statistics.pipeline_artifacts_size).to eq(0)
end
end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 4ef2ddd218a..ca1f06370d4 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -175,12 +175,13 @@ RSpec.describe Namespace do
end
describe '.with_statistics' do
- let(:namespace) { create :namespace }
+ let_it_be(:namespace) { create(:namespace) }
let(:project1) do
create(:project,
namespace: namespace,
statistics: build(:project_statistics,
+ namespace: namespace,
repository_size: 101,
wiki_size: 505,
lfs_objects_size: 202,
@@ -193,6 +194,7 @@ RSpec.describe Namespace do
create(:project,
namespace: namespace,
statistics: build(:project_statistics,
+ namespace: namespace,
repository_size: 10,
wiki_size: 50,
lfs_objects_size: 20,
@@ -391,14 +393,14 @@ RSpec.describe Namespace do
let(:uploads_dir) { FileUploader.root }
let(:pages_dir) { File.join(TestEnv.pages_path) }
- def expect_project_directories_at(namespace_path)
+ def expect_project_directories_at(namespace_path, with_pages: true)
expected_repository_path = File.join(TestEnv.repos_path, namespace_path, 'the-project.git')
expected_upload_path = File.join(uploads_dir, namespace_path, 'the-project')
expected_pages_path = File.join(pages_dir, namespace_path, 'the-project')
expect(File.directory?(expected_repository_path)).to be_truthy
expect(File.directory?(expected_upload_path)).to be_truthy
- expect(File.directory?(expected_pages_path)).to be_truthy
+ expect(File.directory?(expected_pages_path)).to be(with_pages)
end
before do
@@ -407,43 +409,156 @@ RSpec.describe Namespace do
FileUtils.mkdir_p(File.join(pages_dir, project.full_path))
end
+ after do
+ FileUtils.remove_entry(File.join(TestEnv.repos_path, parent.full_path), true)
+ FileUtils.remove_entry(File.join(TestEnv.repos_path, new_parent.full_path), true)
+ FileUtils.remove_entry(File.join(TestEnv.repos_path, child.full_path), true)
+ FileUtils.remove_entry(File.join(uploads_dir, project.full_path), true)
+ FileUtils.remove_entry(pages_dir, true)
+ end
+
context 'renaming child' do
- it 'correctly moves the repository, uploads and pages' do
- child.update!(path: 'renamed')
+ context 'when no projects have pages deployed' do
+ it 'moves the repository and uploads', :sidekiq_inline do
+ project.pages_metadatum.update!(deployed: false)
+ child.update!(path: 'renamed')
- expect_project_directories_at('parent/renamed')
+ expect_project_directories_at('parent/renamed', with_pages: false)
+ end
+ end
+
+ context 'when the project has pages deployed' do
+ before do
+ project.pages_metadatum.update!(deployed: true)
+ end
+
+ it 'correctly moves the repository, uploads and pages', :sidekiq_inline do
+ child.update!(path: 'renamed')
+
+ expect_project_directories_at('parent/renamed')
+ end
+
+ it 'performs the move async of pages async' do
+ expect(PagesTransferWorker).to receive(:perform_async).with('rename_namespace', ['parent/child', 'parent/renamed'])
+
+ child.update!(path: 'renamed')
+ end
end
end
context 'renaming parent' do
- it 'correctly moves the repository, uploads and pages' do
- parent.update!(path: 'renamed')
+ context 'when no projects have pages deployed' do
+ it 'moves the repository and uploads', :sidekiq_inline do
+ project.pages_metadatum.update!(deployed: false)
+ parent.update!(path: 'renamed')
- expect_project_directories_at('renamed/child')
+ expect_project_directories_at('renamed/child', with_pages: false)
+ end
+ end
+
+ context 'when the project has pages deployed' do
+ before do
+ project.pages_metadatum.update!(deployed: true)
+ end
+
+ it 'correctly moves the repository, uploads and pages', :sidekiq_inline do
+ parent.update!(path: 'renamed')
+
+ expect_project_directories_at('renamed/child')
+ end
+
+ it 'performs the move async of pages async' do
+ expect(PagesTransferWorker).to receive(:perform_async).with('rename_namespace', %w(parent renamed))
+
+ parent.update!(path: 'renamed')
+ end
end
end
context 'moving from one parent to another' do
- it 'correctly moves the repository, uploads and pages' do
- child.update!(parent: new_parent)
+ context 'when no projects have pages deployed' do
+ it 'moves the repository and uploads', :sidekiq_inline do
+ project.pages_metadatum.update!(deployed: false)
+ child.update!(parent: new_parent)
+
+ expect_project_directories_at('new_parent/child', with_pages: false)
+ end
+ end
- expect_project_directories_at('new_parent/child')
+ context 'when the project has pages deployed' do
+ before do
+ project.pages_metadatum.update!(deployed: true)
+ end
+
+ it 'correctly moves the repository, uploads and pages', :sidekiq_inline do
+ child.update!(parent: new_parent)
+
+ expect_project_directories_at('new_parent/child')
+ end
+
+ it 'performs the move async of pages async' do
+ expect(PagesTransferWorker).to receive(:perform_async).with('move_namespace', %w(child parent new_parent))
+
+ child.update!(parent: new_parent)
+ end
end
end
context 'moving from having a parent to root' do
- it 'correctly moves the repository, uploads and pages' do
- child.update!(parent: nil)
+ context 'when no projects have pages deployed' do
+ it 'moves the repository and uploads', :sidekiq_inline do
+ project.pages_metadatum.update!(deployed: false)
+ child.update!(parent: nil)
+
+ expect_project_directories_at('child', with_pages: false)
+ end
+ end
+
+ context 'when the project has pages deployed' do
+ before do
+ project.pages_metadatum.update!(deployed: true)
+ end
+
+ it 'correctly moves the repository, uploads and pages', :sidekiq_inline do
+ child.update!(parent: nil)
- expect_project_directories_at('child')
+ expect_project_directories_at('child')
+ end
+
+ it 'performs the move async of pages async' do
+ expect(PagesTransferWorker).to receive(:perform_async).with('move_namespace', ['child', 'parent', nil])
+
+ child.update!(parent: nil)
+ end
end
end
context 'moving from root to having a parent' do
- it 'correctly moves the repository, uploads and pages' do
- parent.update!(parent: new_parent)
+ context 'when no projects have pages deployed' do
+ it 'moves the repository and uploads', :sidekiq_inline do
+ project.pages_metadatum.update!(deployed: false)
+ parent.update!(parent: new_parent)
- expect_project_directories_at('new_parent/parent/child')
+ expect_project_directories_at('new_parent/parent/child', with_pages: false)
+ end
+ end
+
+ context 'when the project has pages deployed' do
+ before do
+ project.pages_metadatum.update!(deployed: true)
+ end
+
+ it 'correctly moves the repository, uploads and pages', :sidekiq_inline do
+ parent.update!(parent: new_parent)
+
+ expect_project_directories_at('new_parent/parent/child')
+ end
+
+ it 'performs the move async of pages async' do
+ expect(PagesTransferWorker).to receive(:perform_async).with('move_namespace', ['parent', nil, 'new_parent'])
+
+ parent.update!(parent: new_parent)
+ end
end
end
end
@@ -588,6 +703,21 @@ RSpec.describe Namespace do
end
end
+ describe ".clean_name" do
+ context "when the name complies with the group name regex" do
+ it "returns the name as is" do
+ valid_name = "Hello - World _ (Hi.)"
+ expect(described_class.clean_name(valid_name)).to eq(valid_name)
+ end
+ end
+
+ context "when the name does not comply with the group name regex" do
+ it "sanitizes the name by replacing all invalid char sequences with a space" do
+ expect(described_class.clean_name("Green'! Test~~~")).to eq("Green Test")
+ end
+ end
+ end
+
describe "#default_branch_protection" do
let(:namespace) { create(:namespace) }
let(:default_branch_protection) { nil }
@@ -1101,6 +1231,27 @@ RSpec.describe Namespace do
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
+ child_1_1 = create(:group, parent: parent_1) # Two projects, one with pages
+ child_1_2 = create(:group, parent: parent_1) # One project, no pages
+ parent_2 = create(:group) # No projects
+
+ create(:project, group: child_1_1).tap do |project|
+ project.pages_metadatum.update!(deployed: true)
+ end
+
+ create(:project, group: child_1_1)
+ create(:project, group: child_1_2)
+
+ expect(parent_1.any_project_with_pages_deployed?).to be(true)
+ expect(child_1_1.any_project_with_pages_deployed?).to be(true)
+ expect(child_1_2.any_project_with_pages_deployed?).to be(false)
+ expect(parent_2.any_project_with_pages_deployed?).to be(false)
+ end
+ end
+
describe '#has_parent?' do
it 'returns true when the group has a parent' do
group = create(:group, :nested)
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 7edd7849bbe..a3417ee5fc7 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -67,7 +67,7 @@ RSpec.describe Note do
end
context 'when noteable is a personal snippet' do
- subject { build(:note_on_personal_snippet) }
+ subject { build(:note_on_personal_snippet, noteable: create(:personal_snippet)) }
it 'is valid without project' do
is_expected.to be_valid
@@ -109,7 +109,8 @@ RSpec.describe Note do
describe 'callbacks' do
describe '#notify_after_create' do
it 'calls #after_note_created on the noteable' do
- note = build(:note)
+ noteable = create(:issue)
+ note = build(:note, project: noteable.project, noteable: noteable)
expect(note).to receive(:notify_after_create).and_call_original
expect(note.noteable).to receive(:after_note_created).with(note)
@@ -285,6 +286,56 @@ RSpec.describe Note do
end
end
+ describe "noteable_author?" do
+ let(:user1) { create(:user) }
+ let(:user2) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+
+ context 'when note is on commit' do
+ let(:noteable) { create(:commit, project: project, author: user1) }
+
+ context 'if user is the noteable author' do
+ let(:note) { create(:discussion_note_on_commit, commit_id: noteable.id, project: project, author: user1) }
+ let(:diff_note) { create(:diff_note_on_commit, commit_id: noteable.id, project: project, author: user1) }
+
+ it 'returns true' do
+ expect(note.noteable_author?(noteable)).to be true
+ expect(diff_note.noteable_author?(noteable)).to be true
+ end
+ end
+
+ context 'if user is not the noteable author' do
+ let(:note) { create(:discussion_note_on_commit, commit_id: noteable.id, project: project, author: user2) }
+ let(:diff_note) { create(:diff_note_on_commit, commit_id: noteable.id, project: project, author: user2) }
+
+ it 'returns false' do
+ expect(note.noteable_author?(noteable)).to be false
+ expect(diff_note.noteable_author?(noteable)).to be false
+ end
+ end
+ end
+
+ context 'when note is on issue' do
+ let(:noteable) { create(:issue, project: project, author: user1) }
+
+ context 'if user is the noteable author' do
+ let(:note) { create(:note, noteable: noteable, author: user1, project: project) }
+
+ it 'returns true' do
+ expect(note.noteable_author?(noteable)).to be true
+ end
+ end
+
+ context 'if user is not the noteable author' do
+ let(:note) { create(:note, noteable: noteable, author: user2, project: project) }
+
+ it 'returns false' do
+ expect(note.noteable_author?(noteable)).to be false
+ end
+ end
+ end
+ end
+
describe "edited?" do
let(:note) { build(:note, updated_by_id: nil, created_at: Time.current, updated_at: Time.current + 5.hours) }
@@ -840,7 +891,8 @@ RSpec.describe Note do
let(:html) { '<p>some html</p>'}
context 'note for a project snippet' do
- let(:note) { build(:note_on_project_snippet) }
+ let(:snippet) { create(:project_snippet) }
+ let(:note) { build(:note_on_project_snippet, project: snippet.project, noteable: snippet) }
before do
expect(Banzai::Renderer).to receive(:cacheless_render_field)
@@ -855,7 +907,8 @@ RSpec.describe Note do
end
context 'note for a personal snippet' do
- let(:note) { build(:note_on_personal_snippet) }
+ let(:snippet) { create(:personal_snippet) }
+ let(:note) { build(:note_on_personal_snippet, noteable: snippet) }
before do
expect(Banzai::Renderer).to receive(:cacheless_render_field)
@@ -889,7 +942,7 @@ RSpec.describe Note do
context 'for a note on a commit' do
it 'returns true' do
- note = build(:note_on_commit)
+ note = build(:note_on_commit, project: create(:project, :repository))
expect(note.can_be_discussion_note?).to be_truthy
end
@@ -913,7 +966,7 @@ RSpec.describe Note do
context 'for a diff note on commit' do
it 'returns false' do
- note = build(:diff_note_on_commit)
+ note = build(:diff_note_on_commit, project: create(:project, :repository))
expect(note.can_be_discussion_note?).to be_falsey
end
@@ -1143,7 +1196,8 @@ RSpec.describe Note do
end
describe 'expiring ETag cache' do
- let(:note) { build(:note_on_issue) }
+ let_it_be(:issue) { create(:issue) }
+ let(:note) { build(:note, project: issue.project, noteable: issue) }
def expect_expiration(noteable)
expect_any_instance_of(Gitlab::EtagCaching::Store)
@@ -1224,22 +1278,6 @@ RSpec.describe Note do
end
end
- describe '#special_role=' do
- let(:role) { Note::SpecialRole::FIRST_TIME_CONTRIBUTOR }
-
- it 'assigns role' do
- subject.special_role = role
-
- expect(subject.special_role).to eq(role)
- end
-
- it 'does not assign unknown role' do
- expect { subject.special_role = :bogus }.to raise_error(/Role is undefined/)
-
- expect(subject.special_role).to be_nil
- end
- end
-
describe '#parent' do
it 'returns project for project notes' do
project = create(:project)
@@ -1416,4 +1454,20 @@ RSpec.describe Note do
expect(note.parent_user).to be_nil
end
end
+
+ describe '#skip_notification?' do
+ subject(:skip_notification?) { note.skip_notification? }
+
+ context 'when there is no review' do
+ let(:note) { build(:note) }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when the review exists' do
+ let(:note) { build(:note, :with_review) }
+
+ it { is_expected.to be_truthy }
+ end
+ end
end
diff --git a/spec/models/operations/feature_flag_scope_spec.rb b/spec/models/operations/feature_flag_scope_spec.rb
new file mode 100644
index 00000000000..29d338d8b29
--- /dev/null
+++ b/spec/models/operations/feature_flag_scope_spec.rb
@@ -0,0 +1,391 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlagScope do
+ describe 'associations' do
+ it { is_expected.to belong_to(:feature_flag) }
+ end
+
+ describe 'validations' do
+ context 'when duplicate environment scope is going to be created' do
+ let!(:existing_feature_flag_scope) do
+ create(:operations_feature_flag_scope)
+ end
+
+ let(:new_feature_flag_scope) do
+ build(:operations_feature_flag_scope,
+ feature_flag: existing_feature_flag_scope.feature_flag,
+ environment_scope: existing_feature_flag_scope.environment_scope)
+ end
+
+ it 'validates uniqueness of environment scope' do
+ new_feature_flag_scope.save
+
+ expect(new_feature_flag_scope.errors[:environment_scope])
+ .to include("(#{existing_feature_flag_scope.environment_scope})" \
+ " has already been taken")
+ end
+ end
+
+ context 'when environment scope of a default scope is updated' do
+ let!(:feature_flag) { create(:operations_feature_flag) }
+ let!(:scope_default) { feature_flag.default_scope }
+
+ it 'keeps default scope intact' do
+ scope_default.update(environment_scope: 'review/*')
+
+ expect(scope_default.errors[:environment_scope])
+ .to include("cannot be changed from default scope")
+ end
+ end
+
+ context 'when a default scope is destroyed' do
+ let!(:feature_flag) { create(:operations_feature_flag) }
+ let!(:scope_default) { feature_flag.default_scope }
+
+ it 'prevents from destroying the default scope' do
+ expect { scope_default.destroy! }.to raise_error(ActiveRecord::ReadOnlyRecord)
+ end
+ end
+
+ describe 'strategy validations' do
+ it 'handles null strategies which can occur while adding the column during migration' do
+ scope = create(:operations_feature_flag_scope, active: true)
+ allow(scope).to receive(:strategies).and_return(nil)
+
+ scope.active = false
+ scope.save
+
+ expect(scope.errors[:strategies]).to be_empty
+ end
+
+ it 'validates multiple strategies' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: "default", parameters: {} },
+ { name: "invalid", parameters: {} }])
+
+ expect(scope.errors[:strategies]).not_to be_empty
+ end
+
+ where(:invalid_value) do
+ [{}, 600, "bad", [{ name: 'default', parameters: {} }, 300]]
+ end
+ with_them do
+ it 'must be an array of strategy hashes' do
+ scope = create(:operations_feature_flag_scope)
+
+ scope.strategies = invalid_value
+ scope.save
+
+ expect(scope.errors[:strategies]).to eq(['must be an array of strategy hashes'])
+ end
+ end
+
+ describe 'name' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:name, :params, :expected) do
+ 'default' | {} | []
+ 'gradualRolloutUserId' | { groupId: 'mygroup', percentage: '50' } | []
+ 'userWithId' | { userIds: 'sam' } | []
+ 5 | nil | ['strategy name is invalid']
+ nil | nil | ['strategy name is invalid']
+ "nothing" | nil | ['strategy name is invalid']
+ "" | nil | ['strategy name is invalid']
+ 40.0 | nil | ['strategy name is invalid']
+ {} | nil | ['strategy name is invalid']
+ [] | nil | ['strategy name is invalid']
+ end
+ with_them do
+ it 'must be one of "default", "gradualRolloutUserId", or "userWithId"' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: name, parameters: params }])
+
+ expect(scope.errors[:strategies]).to eq(expected)
+ end
+ end
+ end
+
+ describe 'parameters' do
+ context 'when the strategy name is gradualRolloutUserId' do
+ it 'must have parameters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId' }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+
+ where(:invalid_parameters) do
+ [nil, {}, { percentage: '40', groupId: 'mygroup', userIds: '4' }, { percentage: '40' },
+ { percentage: '40', groupId: 'mygroup', extra: nil }, { groupId: 'mygroup' }]
+ end
+ with_them do
+ it 'must have valid parameters for the strategy' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: invalid_parameters }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'allows the parameters in any order' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { percentage: '10', groupId: 'mygroup' } }])
+
+ expect(scope.errors[:strategies]).to be_empty
+ end
+
+ describe 'percentage' do
+ where(:invalid_value) do
+ [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100",
+ "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", " ", "\r\n", "\n", "\t",
+ "\n10", "20\n", "\n100", "100\n", "\n ", nil]
+ end
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'mygroup', percentage: invalid_value } }])
+
+ expect(scope.errors[:strategies]).to eq(['percentage must be a string between 0 and 100 inclusive'])
+ end
+ end
+
+ where(:valid_value) do
+ %w[0 1 10 38 100 93]
+ end
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'mygroup', percentage: valid_value } }])
+
+ expect(scope.errors[:strategies]).to eq([])
+ end
+ end
+ end
+
+ describe 'groupId' do
+ where(:invalid_value) do
+ [nil, 4, 50.0, {}, 'spaces bad', 'bad$', '%bad', '<bad', 'bad>', '!bad',
+ '.bad', 'Bad', 'bad1', "", " ", "b" * 33, "ba_d", "ba\nd"]
+ end
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: invalid_value, percentage: '40' } }])
+
+ expect(scope.errors[:strategies]).to eq(['groupId parameter is invalid'])
+ end
+ end
+
+ where(:valid_value) do
+ ["somegroup", "anothergroup", "okay", "g", "a" * 32]
+ end
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: valid_value, percentage: '40' } }])
+
+ expect(scope.errors[:strategies]).to eq([])
+ end
+ end
+ end
+ end
+
+ context 'when the strategy name is userWithId' do
+ it 'must have parameters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'userWithId' }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+
+ where(:invalid_parameters) do
+ [nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}]
+ end
+ with_them do
+ it 'must have valid parameters for the strategy' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'userWithId', parameters: invalid_parameters }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+ end
+
+ describe 'userIds' do
+ where(:valid_value) do
+ ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike",
+ "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0",
+ "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed",
+ "a" * 256, "a,#{'b' * 256},ccc", "many spaces"]
+ end
+ with_them do
+ it 'is valid with a string of comma separated values' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'userWithId', parameters: { userIds: valid_value } }])
+
+ expect(scope.errors[:strategies]).to be_empty
+ end
+ end
+
+ where(:invalid_value) do
+ [1, 2.5, {}, [], nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r",
+ "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ",
+ " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"]
+ end
+ with_them do
+ it 'is invalid' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'userWithId', parameters: { userIds: invalid_value } }])
+
+ expect(scope.errors[:strategies]).to include(
+ 'userIds must be a string of unique comma separated values each 256 characters or less'
+ )
+ end
+ end
+ end
+ end
+
+ context 'when the strategy name is default' do
+ it 'must have parameters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'default' }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+
+ where(:invalid_value) do
+ [{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil, [], 2.5]
+ end
+ with_them do
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'default',
+ parameters: invalid_value }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'default',
+ parameters: {} }])
+
+ expect(scope.errors[:strategies]).to be_empty
+ end
+ end
+ end
+ end
+ end
+
+ describe '.enabled' do
+ subject { described_class.enabled }
+
+ let!(:feature_flag_scope) do
+ create(:operations_feature_flag_scope, active: active)
+ end
+
+ context 'when scope is active' do
+ let(:active) { true }
+
+ it 'returns the scope' do
+ is_expected.to include(feature_flag_scope)
+ end
+ end
+
+ context 'when scope is inactive' do
+ let(:active) { false }
+
+ it 'returns an empty array' do
+ is_expected.not_to include(feature_flag_scope)
+ end
+ end
+ end
+
+ describe '.disabled' do
+ subject { described_class.disabled }
+
+ let!(:feature_flag_scope) do
+ create(:operations_feature_flag_scope, active: active)
+ end
+
+ context 'when scope is active' do
+ let(:active) { true }
+
+ it 'returns an empty array' do
+ is_expected.not_to include(feature_flag_scope)
+ end
+ end
+
+ context 'when scope is inactive' do
+ let(:active) { false }
+
+ it 'returns the scope' do
+ is_expected.to include(feature_flag_scope)
+ end
+ end
+ end
+
+ describe '.for_unleash_client' do
+ it 'returns scopes for the specified project' do
+ project1 = create(:project)
+ project2 = create(:project)
+ expected_feature_flag = create(:operations_feature_flag, project: project1)
+ create(:operations_feature_flag, project: project2)
+
+ scopes = described_class.for_unleash_client(project1, 'sandbox').to_a
+
+ expect(scopes).to contain_exactly(*expected_feature_flag.scopes)
+ end
+
+ it 'returns a scope that matches exactly over a match with a wild card' do
+ project = create(:project)
+ feature_flag = create(:operations_feature_flag, project: project)
+ create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production*')
+ expected_scope = create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production')
+
+ scopes = described_class.for_unleash_client(project, 'production').to_a
+
+ expect(scopes).to contain_exactly(expected_scope)
+ end
+ end
+end
diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb
new file mode 100644
index 00000000000..83d6c6b95a3
--- /dev/null
+++ b/spec/models/operations/feature_flag_spec.rb
@@ -0,0 +1,258 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlag do
+ include FeatureFlagHelpers
+
+ subject { create(:operations_feature_flag) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to have_many(:scopes) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
+ it { is_expected.to define_enum_for(:version).with_values(legacy_flag: 1, new_version_flag: 2) }
+
+ context 'a version 1 feature flag' do
+ it 'is valid if associated with Operations::FeatureFlagScope models' do
+ project = create(:project)
+ feature_flag = described_class.create({ name: 'test', project: project, version: 1,
+ scopes_attributes: [{ environment_scope: '*', active: false }] })
+
+ expect(feature_flag).to be_valid
+ end
+
+ it 'is invalid if associated with Operations::FeatureFlags::Strategy models' do
+ project = create(:project)
+ feature_flag = described_class.create({ name: 'test', project: project, version: 1,
+ strategies_attributes: [{ name: 'default', parameters: {} }] })
+
+ expect(feature_flag.errors.messages).to eq({
+ version_associations: ["version 1 feature flags may not have strategies"]
+ })
+ end
+ end
+
+ context 'a version 2 feature flag' do
+ it 'is invalid if associated with Operations::FeatureFlagScope models' do
+ project = create(:project)
+ feature_flag = described_class.create({ name: 'test', project: project, version: 2,
+ scopes_attributes: [{ environment_scope: '*', active: false }] })
+
+ expect(feature_flag.errors.messages).to eq({
+ version_associations: ["version 2 feature flags may not have scopes"]
+ })
+ end
+
+ it 'is valid if associated with Operations::FeatureFlags::Strategy models' do
+ project = create(:project)
+ feature_flag = described_class.create({ name: 'test', project: project, version: 2,
+ strategies_attributes: [{ name: 'default', parameters: {} }] })
+
+ expect(feature_flag).to be_valid
+ end
+ end
+
+ it_behaves_like 'AtomicInternalId', validate_presence: true do
+ let(:internal_id_attribute) { :iid }
+ let(:instance) { build(:operations_feature_flag) }
+ let(:scope) { :project }
+ let(:scope_attrs) { { project: instance.project } }
+ let(:usage) { :operations_feature_flags }
+ end
+ end
+
+ describe 'feature flag version' do
+ it 'defaults to 1 if unspecified' do
+ project = create(:project)
+
+ feature_flag = described_class.create(name: 'my_flag', project: project, active: true)
+
+ expect(feature_flag).to be_valid
+ expect(feature_flag.version_before_type_cast).to eq(1)
+ end
+ end
+
+ describe 'Scope creation' do
+ subject { described_class.new(**params) }
+
+ let(:project) { create(:project) }
+
+ let(:params) do
+ { name: 'test', project: project, scopes_attributes: scopes_attributes }
+ end
+
+ let(:scopes_attributes) do
+ [{ environment_scope: '*', active: false },
+ { environment_scope: 'review/*', active: true }]
+ end
+
+ it { is_expected.to be_valid }
+
+ context 'when the first scope is not wildcard' do
+ let(:scopes_attributes) do
+ [{ environment_scope: 'review/*', active: true },
+ { environment_scope: '*', active: false }]
+ end
+
+ it { is_expected.not_to be_valid }
+ end
+ end
+
+ describe 'the default scope' do
+ let_it_be(:project) { create(:project) }
+
+ context 'with a version 1 feature flag' do
+ it 'creates a default scope' do
+ feature_flag = described_class.create({ name: 'test', project: project, scopes_attributes: [], version: 1 })
+
+ expect(feature_flag.scopes.count).to eq(1)
+ expect(feature_flag.scopes.first.environment_scope).to eq('*')
+ end
+
+ it 'allows specifying the default scope in the parameters' do
+ feature_flag = described_class.create({ name: 'test', project: project,
+ scopes_attributes: [{ environment_scope: '*', active: false },
+ { environment_scope: 'review/*', active: true }], version: 1 })
+
+ expect(feature_flag.scopes.count).to eq(2)
+ expect(feature_flag.scopes.first.environment_scope).to eq('*')
+ end
+ end
+
+ context 'with a version 2 feature flag' do
+ it 'does not create a default scope' do
+ feature_flag = described_class.create({ name: 'test', project: project, scopes_attributes: [], version: 2 })
+
+ expect(feature_flag.scopes).to eq([])
+ end
+ end
+ end
+
+ describe '.enabled' do
+ subject { described_class.enabled }
+
+ context 'when the feature flag is active' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: true) }
+
+ it 'returns the flag' do
+ is_expected.to eq([feature_flag])
+ end
+ end
+
+ context 'when the feature flag is active and all scopes are inactive' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: true) }
+
+ it 'returns the flag' do
+ feature_flag.default_scope.update!(active: false)
+
+ is_expected.to eq([feature_flag])
+ end
+ end
+
+ context 'when the feature flag is inactive' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: false) }
+
+ it 'does not return the flag' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when the feature flag is inactive and all scopes are active' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: false) }
+
+ it 'does not return the flag' do
+ feature_flag.default_scope.update!(active: true)
+
+ is_expected.to be_empty
+ end
+ end
+ end
+
+ describe '.disabled' do
+ subject { described_class.disabled }
+
+ context 'when the feature flag is active' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: true) }
+
+ it 'does not return the flag' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when the feature flag is active and all scopes are inactive' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: true) }
+
+ it 'does not return the flag' do
+ feature_flag.default_scope.update!(active: false)
+
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when the feature flag is inactive' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: false) }
+
+ it 'returns the flag' do
+ is_expected.to eq([feature_flag])
+ end
+ end
+
+ context 'when the feature flag is inactive and all scopes are active' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: false) }
+
+ it 'returns the flag' do
+ feature_flag.default_scope.update!(active: true)
+
+ is_expected.to eq([feature_flag])
+ end
+ end
+ end
+
+ describe '.for_unleash_client' do
+ let_it_be(:project) { create(:project) }
+ let!(:feature_flag) do
+ create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+ end
+
+ let!(:strategy) do
+ create(:operations_strategy, feature_flag: feature_flag,
+ name: 'default', parameters: {})
+ end
+
+ it 'matches wild cards in the scope' do
+ create(:operations_scope, strategy: strategy, environment_scope: 'review/*')
+
+ flags = described_class.for_unleash_client(project, 'review/feature-branch')
+
+ expect(flags).to eq([feature_flag])
+ end
+
+ it 'matches wild cards case sensitively' do
+ create(:operations_scope, strategy: strategy, environment_scope: 'Staging/*')
+
+ flags = described_class.for_unleash_client(project, 'staging/feature')
+
+ expect(flags).to eq([])
+ end
+
+ it 'returns feature flags ordered by id' do
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+ feature_flag_b = create(:operations_feature_flag, project: project,
+ name: 'feature2', active: true, version: 2)
+ strategy_b = create(:operations_strategy, feature_flag: feature_flag_b,
+ name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy_b, environment_scope: '*')
+
+ flags = described_class.for_unleash_client(project, 'production')
+
+ expect(flags.map(&:id)).to eq([feature_flag.id, feature_flag_b.id])
+ end
+ end
+end
diff --git a/spec/models/operations/feature_flags/strategy_spec.rb b/spec/models/operations/feature_flags/strategy_spec.rb
new file mode 100644
index 00000000000..04e3ef26e9d
--- /dev/null
+++ b/spec/models/operations/feature_flags/strategy_spec.rb
@@ -0,0 +1,323 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlags::Strategy do
+ let_it_be(:project) { create(:project) }
+
+ describe 'validations' do
+ it do
+ is_expected.to validate_inclusion_of(:name)
+ .in_array(%w[default gradualRolloutUserId userWithId gitlabUserList])
+ .with_message('strategy name is invalid')
+ end
+
+ describe 'parameters' do
+ context 'when the strategy name is invalid' do
+ where(:invalid_name) do
+ [nil, {}, [], 'nothing', 3]
+ end
+ with_them do
+ it 'skips parameters validation' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: invalid_name, parameters: { bad: 'params' })
+
+ expect(strategy.errors[:name]).to eq(['strategy name is invalid'])
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+ end
+ end
+
+ context 'when the strategy name is gradualRolloutUserId' do
+ where(:invalid_parameters) do
+ [nil, {}, { percentage: '40', groupId: 'mygroup', userIds: '4' }, { percentage: '40' },
+ { percentage: '40', groupId: 'mygroup', extra: nil }, { groupId: 'mygroup' }]
+ end
+ with_them do
+ it 'must have valid parameters for the strategy' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId', parameters: invalid_parameters)
+
+ expect(strategy.errors[:parameters]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'allows the parameters in any order' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { percentage: '10', groupId: 'mygroup' })
+
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+
+ describe 'percentage' do
+ where(:invalid_value) do
+ [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100",
+ "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", " ", "\r\n", "\n", "\t",
+ "\n10", "20\n", "\n100", "100\n", "\n ", nil]
+ end
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'mygroup', percentage: invalid_value })
+
+ expect(strategy.errors[:parameters]).to eq(['percentage must be a string between 0 and 100 inclusive'])
+ end
+ end
+
+ where(:valid_value) do
+ %w[0 1 10 38 100 93]
+ end
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'mygroup', percentage: valid_value })
+
+ expect(strategy.errors[:parameters]).to eq([])
+ end
+ end
+ end
+
+ describe 'groupId' do
+ where(:invalid_value) do
+ [nil, 4, 50.0, {}, 'spaces bad', 'bad$', '%bad', '<bad', 'bad>', '!bad',
+ '.bad', 'Bad', 'bad1', "", " ", "b" * 33, "ba_d", "ba\nd"]
+ end
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: invalid_value, percentage: '40' })
+
+ expect(strategy.errors[:parameters]).to eq(['groupId parameter is invalid'])
+ end
+ end
+
+ where(:valid_value) do
+ ["somegroup", "anothergroup", "okay", "g", "a" * 32]
+ end
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: valid_value, percentage: '40' })
+
+ expect(strategy.errors[:parameters]).to eq([])
+ end
+ end
+ end
+ end
+
+ context 'when the strategy name is userWithId' do
+ where(:invalid_parameters) do
+ [nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}]
+ end
+ with_them do
+ it 'must have valid parameters for the strategy' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId', parameters: invalid_parameters)
+
+ expect(strategy.errors[:parameters]).to eq(['parameters are invalid'])
+ end
+ end
+
+ describe 'userIds' do
+ where(:valid_value) do
+ ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike",
+ "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0",
+ "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed",
+ "a" * 256, "a,#{'b' * 256},ccc", "many spaces"]
+ end
+ with_them do
+ it 'is valid with a string of comma separated values' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId', parameters: { userIds: valid_value })
+
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+ end
+
+ where(:invalid_value) do
+ [1, 2.5, {}, [], nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r",
+ "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ",
+ " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"]
+ end
+ with_them do
+ it 'is invalid' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId', parameters: { userIds: invalid_value })
+
+ expect(strategy.errors[:parameters]).to include(
+ 'userIds must be a string of unique comma separated values each 256 characters or less'
+ )
+ end
+ end
+ end
+ end
+
+ context 'when the strategy name is default' do
+ where(:invalid_value) do
+ [{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil, [], 2.5]
+ end
+ with_them do
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'default',
+ parameters: invalid_value)
+
+ expect(strategy.errors[:parameters]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'default',
+ parameters: {})
+
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+ end
+
+ context 'when the strategy name is gitlabUserList' do
+ where(:invalid_value) do
+ [{ groupId: "default", percentage: "7" }, "", "nothing", 7, nil, [], 2.5, { userIds: 'user1' }]
+ end
+ with_them do
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ parameters: invalid_value)
+
+ expect(strategy.errors[:parameters]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ parameters: {})
+
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+ end
+ end
+
+ describe 'associations' do
+ context 'when name is gitlabUserList' do
+ it 'is valid when associated with a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ user_list: user_list,
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to be_empty
+ end
+
+ it 'is invalid without a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to eq(["can't be blank"])
+ end
+
+ it 'is invalid when associated with a user list from another project' do
+ other_project = create(:project)
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: other_project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ user_list: user_list,
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to eq(['must belong to the same project'])
+ end
+ end
+
+ context 'when name is default' do
+ it 'is invalid when associated with a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'default',
+ user_list: user_list,
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to eq(['must be blank'])
+ end
+
+ it 'is valid without a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'default',
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to be_empty
+ end
+ end
+
+ context 'when name is userWithId' do
+ it 'is invalid when associated with a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId',
+ user_list: user_list,
+ parameters: { userIds: 'user1' })
+
+ expect(strategy.errors[:user_list]).to eq(['must be blank'])
+ end
+
+ it 'is valid without a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId',
+ parameters: { userIds: 'user1' })
+
+ expect(strategy.errors[:user_list]).to be_empty
+ end
+ end
+
+ context 'when name is gradualRolloutUserId' do
+ it 'is invalid when associated with a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ user_list: user_list,
+ parameters: { groupId: 'default', percentage: '10' })
+
+ expect(strategy.errors[:user_list]).to eq(['must be blank'])
+ end
+
+ it 'is valid without a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '10' })
+
+ expect(strategy.errors[:user_list]).to be_empty
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/operations/feature_flags/user_list_spec.rb b/spec/models/operations/feature_flags/user_list_spec.rb
new file mode 100644
index 00000000000..020416aa7bc
--- /dev/null
+++ b/spec/models/operations/feature_flags/user_list_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlags::UserList do
+ subject { create(:operations_feature_flag_user_list) }
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
+ it { is_expected.to validate_length_of(:name).is_at_least(1).is_at_most(255) }
+
+ describe 'user_xids' do
+ where(:valid_value) do
+ ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike",
+ "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0",
+ "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed",
+ "a" * 256, "a,#{'b' * 256},ccc", "many spaces"]
+ end
+ with_them do
+ it 'is valid with a string of comma separated values' do
+ user_list = described_class.create(user_xids: valid_value)
+
+ expect(user_list.errors[:user_xids]).to be_empty
+ end
+ end
+
+ where(:typecast_value) do
+ [1, 2.5, {}, []]
+ end
+ with_them do
+ it 'automatically casts values of other types' do
+ user_list = described_class.create(user_xids: typecast_value)
+
+ expect(user_list.errors[:user_xids]).to be_empty
+ expect(user_list.user_xids).to eq(typecast_value.to_s)
+ end
+ end
+
+ where(:invalid_value) do
+ [nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r",
+ "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ",
+ " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"]
+ end
+ with_them do
+ it 'is invalid' do
+ user_list = described_class.create(user_xids: invalid_value)
+
+ expect(user_list.errors[:user_xids]).to include(
+ 'user_xids must be a string of unique comma separated values each 256 characters or less'
+ )
+ end
+ end
+ end
+ end
+
+ describe 'url_helpers' do
+ it 'generates paths based on the internal id' do
+ create(:operations_feature_flag_user_list)
+ project_b = create(:project)
+ list_b = create(:operations_feature_flag_user_list, project: project_b)
+
+ path = ::Gitlab::Routing.url_helpers.project_feature_flags_user_list_path(project_b, list_b)
+
+ expect(path).to eq("/#{project_b.full_path}/-/feature_flags_user_lists/#{list_b.iid}")
+ end
+ end
+
+ describe '#destroy' do
+ it 'deletes the model if it is not associated with any feature flag strategies' do
+ project = create(:project)
+ user_list = described_class.create(project: project, name: 'My User List', user_xids: 'user1,user2')
+
+ user_list.destroy
+
+ expect(described_class.count).to eq(0)
+ end
+
+ it 'does not delete the model if it is associated with a feature flag strategy' do
+ project = create(:project)
+ user_list = described_class.create(project: project, name: 'My User List', user_xids: 'user1,user2')
+ feature_flag = create(:operations_feature_flag, :new_version_flag, project: project)
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'gitlabUserList', user_list: user_list)
+
+ user_list.destroy
+
+ expect(described_class.count).to eq(1)
+ expect(::Operations::FeatureFlags::StrategyUserList.count).to eq(1)
+ expect(strategy.reload.user_list).to eq(user_list)
+ expect(strategy.valid?).to eq(true)
+ end
+ end
+
+ it_behaves_like 'AtomicInternalId' do
+ let(:internal_id_attribute) { :iid }
+ let(:instance) { build(:operations_feature_flag_user_list) }
+ let(:scope) { :project }
+ let(:scope_attrs) { { project: instance.project } }
+ let(:usage) { :operations_user_lists }
+ end
+end
diff --git a/spec/models/operations/feature_flags_client_spec.rb b/spec/models/operations/feature_flags_client_spec.rb
new file mode 100644
index 00000000000..05988d676f3
--- /dev/null
+++ b/spec/models/operations/feature_flags_client_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlagsClient do
+ subject { create(:operations_feature_flags_client) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ end
+
+ describe '#token' do
+ it "ensures that token is always set" do
+ expect(subject.token).not_to be_empty
+ end
+ end
+end
diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb
index 7cc8e13d449..6b992dbc2a5 100644
--- a/spec/models/packages/package_file_spec.rb
+++ b/spec/models/packages/package_file_spec.rb
@@ -33,15 +33,17 @@ RSpec.describe Packages::PackageFile, type: :model do
end
context 'updating project statistics' do
+ let_it_be(:package, reload: true) { create(:package) }
+
context 'when the package file has an explicit size' do
it_behaves_like 'UpdateProjectStatistics' do
- subject { build(:package_file, :jar, size: 42) }
+ subject { build(:package_file, :jar, package: package, size: 42) }
end
end
context 'when the package file does not have a size' do
it_behaves_like 'UpdateProjectStatistics' do
- subject { build(:package_file, size: nil) }
+ subject { build(:package_file, package: package, size: nil) }
end
end
end
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index 4170bf595f0..ea1f75d04e7 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Packages::Package, type: :model do
describe 'relationships' do
it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:creator) }
it { is_expected.to have_many(:package_files).dependent(:destroy) }
it { is_expected.to have_many(:dependency_links).inverse_of(:package) }
it { is_expected.to have_many(:tags).inverse_of(:package) }
@@ -84,7 +85,7 @@ RSpec.describe Packages::Package, type: :model do
end
describe 'validations' do
- subject { create(:package) }
+ subject { build(:package) }
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id, :version, :package_type) }
@@ -95,7 +96,7 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.not_to allow_value("my(dom$$$ain)com.my-app").for(:name) }
context 'conan package' do
- subject { create(:conan_package) }
+ subject { build_stubbed(:conan_package) }
let(:fifty_one_characters) {'f_b' * 17}
@@ -112,7 +113,7 @@ RSpec.describe Packages::Package, type: :model do
describe '#version' do
RSpec.shared_examples 'validating version to be SemVer compliant for' do |factory_name|
context "for #{factory_name}" do
- subject { create(factory_name) }
+ subject { build_stubbed(factory_name) }
it { is_expected.to allow_value('1.2.3').for(:version) }
it { is_expected.to allow_value('1.2.3-beta').for(:version) }
@@ -126,7 +127,7 @@ RSpec.describe Packages::Package, type: :model do
end
context 'conan package' do
- subject { create(:conan_package) }
+ subject { build_stubbed(:conan_package) }
let(:fifty_one_characters) {'1.2' * 17}
@@ -142,7 +143,7 @@ RSpec.describe Packages::Package, type: :model do
end
context 'maven package' do
- subject { create(:maven_package) }
+ subject { build_stubbed(:maven_package) }
it { is_expected.to allow_value('0').for(:version) }
it { is_expected.to allow_value('1').for(:version) }
@@ -169,6 +170,92 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.not_to allow_value('%2e%2e%2f1.2.3').for(:version) }
end
+ context 'pypi package' do
+ subject { create(:pypi_package) }
+
+ it { is_expected.to allow_value('0.1').for(:version) }
+ it { is_expected.to allow_value('2.0').for(:version) }
+ it { is_expected.to allow_value('1.2.0').for(:version) }
+ it { is_expected.to allow_value('0100!0.0').for(:version) }
+ it { is_expected.to allow_value('00!1.2').for(:version) }
+ it { is_expected.to allow_value('1.0a').for(:version) }
+ it { is_expected.to allow_value('1.0-a').for(:version) }
+ it { is_expected.to allow_value('1.0.a1').for(:version) }
+ it { is_expected.to allow_value('1.0a1').for(:version) }
+ it { is_expected.to allow_value('1.0-a1').for(:version) }
+ it { is_expected.to allow_value('1.0alpha1').for(:version) }
+ it { is_expected.to allow_value('1.0b1').for(:version) }
+ it { is_expected.to allow_value('1.0beta1').for(:version) }
+ it { is_expected.to allow_value('1.0rc1').for(:version) }
+ it { is_expected.to allow_value('1.0pre1').for(:version) }
+ it { is_expected.to allow_value('1.0preview1').for(:version) }
+ it { is_expected.to allow_value('1.0.dev1').for(:version) }
+ it { is_expected.to allow_value('1.0.DEV1').for(:version) }
+ it { is_expected.to allow_value('1.0.post1').for(:version) }
+ it { is_expected.to allow_value('1.0.rev1').for(:version) }
+ it { is_expected.to allow_value('1.0.r1').for(:version) }
+ it { is_expected.to allow_value('1.0c2').for(:version) }
+ it { is_expected.to allow_value('2012.15').for(:version) }
+ it { is_expected.to allow_value('1.0+5').for(:version) }
+ it { is_expected.to allow_value('1.0+abc.5').for(:version) }
+ it { is_expected.to allow_value('1!1.1').for(:version) }
+ it { is_expected.to allow_value('1.0c3').for(:version) }
+ it { is_expected.to allow_value('1.0rc2').for(:version) }
+ it { is_expected.to allow_value('1.0c1').for(:version) }
+ it { is_expected.to allow_value('1.0b2-346').for(:version) }
+ it { is_expected.to allow_value('1.0b2.post345').for(:version) }
+ it { is_expected.to allow_value('1.0b2.post345.dev456').for(:version) }
+ it { is_expected.to allow_value('1.2.rev33+123456').for(:version) }
+ it { is_expected.to allow_value('1.1.dev1').for(:version) }
+ it { is_expected.to allow_value('1.0b1.dev456').for(:version) }
+ it { is_expected.to allow_value('1.0a12.dev456').for(:version) }
+ it { is_expected.to allow_value('1.0b2').for(:version) }
+ it { is_expected.to allow_value('1.0.dev456').for(:version) }
+ it { is_expected.to allow_value('1.0c1.dev456').for(:version) }
+ it { is_expected.to allow_value('1.0.post456').for(:version) }
+ it { is_expected.to allow_value('1.0.post456.dev34').for(:version) }
+ it { is_expected.to allow_value('1.2+123abc').for(:version) }
+ it { is_expected.to allow_value('1.2+abc').for(:version) }
+ it { is_expected.to allow_value('1.2+abc123').for(:version) }
+ it { is_expected.to allow_value('1.2+abc123def').for(:version) }
+ it { is_expected.to allow_value('1.2+1234.abc').for(:version) }
+ it { is_expected.to allow_value('1.2+123456').for(:version) }
+ it { is_expected.to allow_value('1.2.r32+123456').for(:version) }
+ it { is_expected.to allow_value('1!1.2.rev33+123456').for(:version) }
+ it { is_expected.to allow_value('1.0a12').for(:version) }
+ it { is_expected.to allow_value('1.2.3-45+abcdefgh').for(:version) }
+ it { is_expected.to allow_value('v1.2.3').for(:version) }
+ it { is_expected.not_to allow_value('1.2.3-45-abcdefgh').for(:version) }
+ it { is_expected.not_to allow_value('..1.2.3').for(:version) }
+ it { is_expected.not_to allow_value(' 1.2.3').for(:version) }
+ it { is_expected.not_to allow_value("1.2.3 \r\t").for(:version) }
+ it { is_expected.not_to allow_value("\r\t 1.2.3").for(:version) }
+ it { is_expected.not_to allow_value('1./2.3').for(:version) }
+ it { is_expected.not_to allow_value('1.2.3-4/../../').for(:version) }
+ it { is_expected.not_to allow_value('1.2.3-4%2e%2e%').for(:version) }
+ it { is_expected.not_to allow_value('../../../../../1.2.3').for(:version) }
+ it { is_expected.not_to allow_value('%2e%2e%2f1.2.3').for(:version) }
+ end
+
+ context 'generic package' do
+ subject { build_stubbed(:generic_package) }
+
+ it { is_expected.to validate_presence_of(:version) }
+ it { is_expected.to allow_value('1.2.3').for(:version) }
+ it { is_expected.to allow_value('1.3.350').for(:version) }
+ it { is_expected.not_to allow_value('1.3.350-20201230123456').for(:version) }
+ it { is_expected.not_to allow_value('..1.2.3').for(:version) }
+ it { is_expected.not_to allow_value(' 1.2.3').for(:version) }
+ it { is_expected.not_to allow_value("1.2.3 \r\t").for(:version) }
+ it { is_expected.not_to allow_value("\r\t 1.2.3").for(:version) }
+ it { is_expected.not_to allow_value('1.2.3-4/../../').for(:version) }
+ it { is_expected.not_to allow_value('1.2.3-4%2e%2e%').for(:version) }
+ it { is_expected.not_to allow_value('../../../../../1.2.3').for(:version) }
+ it { is_expected.not_to allow_value('%2e%2e%2f1.2.3').for(:version) }
+ it { is_expected.not_to allow_value('').for(:version) }
+ it { is_expected.not_to allow_value(nil).for(:version) }
+ end
+
it_behaves_like 'validating version to be SemVer compliant for', :npm_package
it_behaves_like 'validating version to be SemVer compliant for', :nuget_package
end
@@ -178,7 +265,7 @@ RSpec.describe Packages::Package, type: :model do
let!(:package) { create(:npm_package) }
it 'will not allow a package of the same name' do
- new_package = build(:npm_package, name: package.name)
+ new_package = build(:npm_package, project: create(:project), name: package.name)
expect(new_package).not_to be_valid
end
@@ -482,4 +569,23 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.to contain_exactly(*tags) }
end
end
+
+ describe 'plan_limits' do
+ Packages::Package.package_types.keys.without('composer').each do |pt|
+ plan_limit_name = if pt == 'generic'
+ "#{pt}_packages_max_file_size"
+ else
+ "#{pt}_max_file_size"
+ end
+
+ context "File size limits for #{pt}" do
+ let(:package) { create("#{pt}_package") }
+
+ it "plan_limits includes column #{plan_limit_name}" do
+ expect { package.project.actual_limits.send(plan_limit_name) }
+ .not_to raise_error(NoMethodError)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/pages/lookup_path_spec.rb b/spec/models/pages/lookup_path_spec.rb
index 38bd9b39a56..cb1938a0113 100644
--- a/spec/models/pages/lookup_path_spec.rb
+++ b/spec/models/pages/lookup_path_spec.rb
@@ -3,20 +3,20 @@
require 'spec_helper'
RSpec.describe Pages::LookupPath do
- let(:project) do
- instance_double(Project,
- id: 12345,
- private_pages?: true,
- pages_https_only?: true,
- full_path: 'the/full/path'
- )
+ let_it_be(:project) do
+ create(:project, :pages_private, pages_https_only: true)
end
subject(:lookup_path) { described_class.new(project) }
+ before do
+ stub_pages_setting(access_control: true, external_https: ["1.1.1.1:443"])
+ stub_artifacts_object_storage
+ end
+
describe '#project_id' do
it 'delegates to Project#id' do
- expect(lookup_path.project_id).to eq(12345)
+ expect(lookup_path.project_id).to eq(project.id)
end
end
@@ -47,12 +47,49 @@ RSpec.describe Pages::LookupPath do
end
describe '#source' do
- it 'sets the source type to "file"' do
- expect(lookup_path.source[:type]).to eq('file')
+ shared_examples 'uses disk storage' do
+ it 'sets the source type to "file"' do
+ expect(lookup_path.source[:type]).to eq('file')
+ end
+
+ it 'sets the source path to the project full path suffixed with "public/' do
+ expect(lookup_path.source[:path]).to eq(project.full_path + "/public/")
+ end
end
- it 'sets the source path to the project full path suffixed with "public/' do
- expect(lookup_path.source[:path]).to eq('the/full/path/public/')
+ include_examples 'uses disk storage'
+
+ context 'when artifact_id from build job is present in pages metadata' do
+ let(:artifacts_archive) { create(:ci_job_artifact, :zip, :remote_store, project: project) }
+
+ before do
+ project.mark_pages_as_deployed(artifacts_archive: artifacts_archive)
+ end
+
+ it 'sets the source type to "zip"' do
+ expect(lookup_path.source[:type]).to eq('zip')
+ end
+
+ it 'sets the source path to the artifacts archive URL' do
+ Timecop.freeze do
+ expect(lookup_path.source[:path]).to eq(artifacts_archive.file.url(expire_at: 1.day.from_now))
+ expect(lookup_path.source[:path]).to include("Expires=86400")
+ end
+ end
+
+ context 'when artifact is not uploaded to object storage' do
+ let(:artifacts_archive) { create(:ci_job_artifact, :zip) }
+
+ include_examples 'uses disk storage'
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(pages_artifacts_archive: false)
+ end
+
+ include_examples 'uses disk storage'
+ end
end
end
diff --git a/spec/models/pages_deployment_spec.rb b/spec/models/pages_deployment_spec.rb
new file mode 100644
index 00000000000..eafff1ed59a
--- /dev/null
+++ b/spec/models/pages_deployment_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PagesDeployment do
+ describe 'associations' do
+ it { is_expected.to belong_to(:project).required }
+ it { is_expected.to belong_to(:ci_build).optional }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:file) }
+ it { is_expected.to validate_presence_of(:size) }
+ it { is_expected.to validate_numericality_of(:size).only_integer.is_greater_than(0) }
+ it { is_expected.to validate_inclusion_of(:file_store).in_array(ObjectStorage::SUPPORTED_STORES) }
+
+ it 'is valid when created from the factory' do
+ expect(create(:pages_deployment)).to be_valid
+ end
+ end
+end
diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb
index 78980f8cdab..1d5369e608e 100644
--- a/spec/models/pages_domain_spec.rb
+++ b/spec/models/pages_domain_spec.rb
@@ -354,16 +354,6 @@ RSpec.describe PagesDomain do
domain.destroy!
end
- it 'delegates to Projects::UpdatePagesConfigurationService when not running async' do
- stub_feature_flags(async_update_pages_config: false)
-
- service = instance_double('Projects::UpdatePagesConfigurationService')
- expect(Projects::UpdatePagesConfigurationService).to receive(:new) { service }
- expect(service).to receive(:execute)
-
- create(:pages_domain, project: project)
- end
-
it "schedules a PagesUpdateConfigurationWorker" do
expect(PagesUpdateConfigurationWorker).to receive(:perform_async).with(project.id)
diff --git a/spec/models/performance_monitoring/prometheus_dashboard_spec.rb b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb
index 61174a7d0c5..634690d5d0b 100644
--- a/spec/models/performance_monitoring/prometheus_dashboard_spec.rb
+++ b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb
@@ -219,20 +219,93 @@ RSpec.describe PerformanceMonitoring::PrometheusDashboard do
end
describe '#schema_validation_warnings' do
- context 'when schema is valid' do
- it 'returns nil' do
- expect(described_class).to receive(:from_json)
- expect(described_class.new.schema_validation_warnings).to be_nil
+ let(:environment) { create(:environment, project: project) }
+ let(:path) { '.gitlab/dashboards/test.yml' }
+ let(:project) { create(:project, :repository, :custom_repo, files: { path => dashboard_schema.to_yaml }) }
+
+ subject(:schema_validation_warnings) { described_class.new(dashboard_schema.merge(path: path, environment: environment)).schema_validation_warnings }
+
+ before do
+ allow(Gitlab::Metrics::Dashboard::Finder).to receive(:find_raw).with(project, dashboard_path: path).and_call_original
+ end
+
+ context 'metrics_dashboard_exhaustive_validations is on' do
+ before do
+ stub_feature_flags(metrics_dashboard_exhaustive_validations: true)
+ end
+
+ context 'when schema is valid' do
+ let(:dashboard_schema) { YAML.safe_load(fixture_file('lib/gitlab/metrics/dashboard/sample_dashboard.yml')) }
+
+ it 'returns empty array' do
+ expect(Gitlab::Metrics::Dashboard::Validator).to receive(:errors).with(dashboard_schema, dashboard_path: path, project: project).and_return([])
+
+ expect(schema_validation_warnings).to eq []
+ end
+ end
+
+ context 'when schema is invalid' do
+ let(:dashboard_schema) { YAML.safe_load(fixture_file('lib/gitlab/metrics/dashboard/dashboard_missing_panel_groups.yml')) }
+
+ it 'returns array with errors messages' do
+ error = ::Gitlab::Metrics::Dashboard::Validator::Errors::SchemaValidationError.new
+
+ expect(Gitlab::Metrics::Dashboard::Validator).to receive(:errors).with(dashboard_schema, dashboard_path: path, project: project).and_return([error])
+
+ expect(schema_validation_warnings).to eq [error.message]
+ end
+ end
+
+ context 'when YAML has wrong syntax' do
+ let(:project) { create(:project, :repository, :custom_repo, files: { path => fixture_file('lib/gitlab/metrics/dashboard/broken_yml_syntax.yml') }) }
+
+ subject(:schema_validation_warnings) { described_class.new(path: path, environment: environment).schema_validation_warnings }
+
+ it 'returns array with errors messages' do
+ expect(Gitlab::Metrics::Dashboard::Validator).not_to receive(:errors)
+
+ expect(schema_validation_warnings).to eq ['Invalid yaml']
+ end
end
end
- context 'when schema is invalid' do
- it 'returns array with errors messages' do
- instance = described_class.new
- instance.errors.add(:test, 'test error')
+ context 'metrics_dashboard_exhaustive_validations is off' do
+ before do
+ stub_feature_flags(metrics_dashboard_exhaustive_validations: false)
+ end
+
+ context 'when schema is valid' do
+ let(:dashboard_schema) { YAML.safe_load(fixture_file('lib/gitlab/metrics/dashboard/sample_dashboard.yml')) }
+
+ it 'returns empty array' do
+ expect(described_class).to receive(:from_json).with(dashboard_schema)
+
+ expect(schema_validation_warnings).to eq []
+ end
+ end
+
+ context 'when schema is invalid' do
+ let(:dashboard_schema) { YAML.safe_load(fixture_file('lib/gitlab/metrics/dashboard/dashboard_missing_panel_groups.yml')) }
+
+ it 'returns array with errors messages' do
+ instance = described_class.new
+ instance.errors.add(:test, 'test error')
+
+ expect(described_class).to receive(:from_json).and_raise(ActiveModel::ValidationError.new(instance))
+ expect(described_class.new.schema_validation_warnings).to eq ['test: test error']
+ end
+ end
- expect(described_class).to receive(:from_json).and_raise(ActiveModel::ValidationError.new(instance))
- expect(described_class.new.schema_validation_warnings).to eq ['test: test error']
+ context 'when YAML has wrong syntax' do
+ let(:project) { create(:project, :repository, :custom_repo, files: { path => fixture_file('lib/gitlab/metrics/dashboard/broken_yml_syntax.yml') }) }
+
+ subject(:schema_validation_warnings) { described_class.new(path: path, environment: environment).schema_validation_warnings }
+
+ it 'returns array with errors messages' do
+ expect(described_class).not_to receive(:from_json)
+
+ expect(schema_validation_warnings).to eq ['Invalid yaml']
+ end
end
end
end
diff --git a/spec/models/product_analytics_event_spec.rb b/spec/models/product_analytics_event_spec.rb
index afdb5b690f8..286729b8398 100644
--- a/spec/models/product_analytics_event_spec.rb
+++ b/spec/models/product_analytics_event_spec.rb
@@ -35,4 +35,29 @@ RSpec.describe ProductAnalyticsEvent, type: :model do
it { expect(described_class.count_by_graph('platform', 7.days)).to eq({ 'app' => 1, 'web' => 2 }) }
it { expect(described_class.count_by_graph('platform', 30.days)).to eq({ 'app' => 1, 'mobile' => 1, 'web' => 2 }) }
end
+
+ describe '.by_category_and_action' do
+ let_it_be(:event) { create(:product_analytics_event, se_category: 'catA', se_action: 'actA') }
+
+ before do
+ create(:product_analytics_event, se_category: 'catA', se_action: 'actB')
+ create(:product_analytics_event, se_category: 'catB', se_action: 'actA')
+ end
+
+ it { expect(described_class.by_category_and_action('catA', 'actA')).to match_array([event]) }
+ end
+
+ describe '.count_collector_tstamp_by_day' do
+ let_it_be(:time_now) { Time.zone.now }
+ let_it_be(:time_ago) { Time.zone.now - 5.days }
+
+ let_it_be(:events) do
+ create_list(:product_analytics_event, 3, collector_tstamp: time_now) +
+ create_list(:product_analytics_event, 2, collector_tstamp: time_ago)
+ end
+
+ subject { described_class.count_collector_tstamp_by_day(7.days) }
+
+ it { is_expected.to eq({ time_now.beginning_of_day => 3, time_ago.beginning_of_day => 2 }) }
+ end
end
diff --git a/spec/models/project_feature_usage_spec.rb b/spec/models/project_feature_usage_spec.rb
new file mode 100644
index 00000000000..908b98ee9c2
--- /dev/null
+++ b/spec/models/project_feature_usage_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ProjectFeatureUsage, type: :model do
+ describe '.jira_dvcs_integrations_enabled_count' do
+ it 'returns count of projects with Jira DVCS Cloud enabled' do
+ create(:project).feature_usage.log_jira_dvcs_integration_usage
+ create(:project).feature_usage.log_jira_dvcs_integration_usage
+
+ expect(described_class.with_jira_dvcs_integration_enabled.count).to eq(2)
+ end
+
+ it 'returns count of projects with Jira DVCS Server enabled' do
+ create(:project).feature_usage.log_jira_dvcs_integration_usage(cloud: false)
+ create(:project).feature_usage.log_jira_dvcs_integration_usage(cloud: false)
+
+ expect(described_class.with_jira_dvcs_integration_enabled(cloud: false).count).to eq(2)
+ end
+ end
+
+ describe '#log_jira_dvcs_integration_usage' do
+ let(:project) { create(:project) }
+
+ subject { project.feature_usage }
+
+ it 'logs Jira DVCS Cloud last sync' do
+ Timecop.freeze do
+ subject.log_jira_dvcs_integration_usage
+
+ expect(subject.jira_dvcs_server_last_sync_at).to be_nil
+ expect(subject.jira_dvcs_cloud_last_sync_at).to be_like_time(Time.current)
+ end
+ end
+
+ it 'logs Jira DVCS Server last sync' do
+ Timecop.freeze do
+ subject.log_jira_dvcs_integration_usage(cloud: false)
+
+ expect(subject.jira_dvcs_server_last_sync_at).to be_like_time(Time.current)
+ expect(subject.jira_dvcs_cloud_last_sync_at).to be_nil
+ end
+ end
+
+ context 'when log_jira_dvcs_integration_usage is called simultaneously for the same project' do
+ it 'logs the latest call' do
+ feature_usage = project.feature_usage
+ feature_usage.log_jira_dvcs_integration_usage
+ first_logged_at = feature_usage.jira_dvcs_cloud_last_sync_at
+
+ Timecop.freeze(1.hour.from_now) do
+ ProjectFeatureUsage.new(project_id: project.id).log_jira_dvcs_integration_usage
+ end
+
+ expect(feature_usage.reload.jira_dvcs_cloud_last_sync_at).to be > first_logged_at
+ end
+ end
+ end
+end
diff --git a/spec/models/project_services/bamboo_service_spec.rb b/spec/models/project_services/bamboo_service_spec.rb
index 4d2474cc56a..45afbcca96d 100644
--- a/spec/models/project_services/bamboo_service_spec.rb
+++ b/spec/models/project_services/bamboo_service_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe BambooService, :use_clean_rails_memory_store_caching do
let_it_be(:project) { create(:project) }
subject(:service) do
- described_class.create(
+ described_class.create!(
project: project,
properties: {
bamboo_url: bamboo_url,
@@ -85,7 +85,7 @@ RSpec.describe BambooService, :use_clean_rails_memory_store_caching do
bamboo_service = service
bamboo_service.bamboo_url = 'http://gitlab1.com'
- bamboo_service.save
+ bamboo_service.save!
expect(bamboo_service.password).to be_nil
end
@@ -94,7 +94,7 @@ RSpec.describe BambooService, :use_clean_rails_memory_store_caching do
bamboo_service = service
bamboo_service.username = 'some_name'
- bamboo_service.save
+ bamboo_service.save!
expect(bamboo_service.password).to eq('password')
end
@@ -104,7 +104,7 @@ RSpec.describe BambooService, :use_clean_rails_memory_store_caching do
bamboo_service.bamboo_url = 'http://gitlab_edited.com'
bamboo_service.password = 'password'
- bamboo_service.save
+ bamboo_service.save!
expect(bamboo_service.password).to eq('password')
expect(bamboo_service.bamboo_url).to eq('http://gitlab_edited.com')
@@ -117,7 +117,7 @@ RSpec.describe BambooService, :use_clean_rails_memory_store_caching do
bamboo_service.bamboo_url = 'http://gitlab_edited.com'
bamboo_service.password = 'password'
- bamboo_service.save
+ bamboo_service.save!
expect(bamboo_service.password).to eq('password')
expect(bamboo_service.bamboo_url).to eq('http://gitlab_edited.com')
diff --git a/spec/models/project_services/buildkite_service_spec.rb b/spec/models/project_services/buildkite_service_spec.rb
index 3d0c2cc1006..f6bf1551bf0 100644
--- a/spec/models/project_services/buildkite_service_spec.rb
+++ b/spec/models/project_services/buildkite_service_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe BuildkiteService, :use_clean_rails_memory_store_caching do
let(:project) { create(:project) }
subject(:service) do
- described_class.create(
+ described_class.create!(
project: project,
properties: {
service_hook: true,
diff --git a/spec/models/project_services/chat_message/merge_message_spec.rb b/spec/models/project_services/chat_message/merge_message_spec.rb
index 45be5212508..02b266e4fae 100644
--- a/spec/models/project_services/chat_message/merge_message_spec.rb
+++ b/spec/models/project_services/chat_message/merge_message_spec.rb
@@ -29,23 +29,6 @@ RSpec.describe ChatMessage::MergeMessage do
}
end
- # Integration point in EE
- context 'when state is overridden' do
- it 'respects the overridden state' do
- allow(subject).to receive(:state_or_action_text) { 'devoured' }
-
- aggregate_failures do
- expect(subject.summary).not_to include('opened')
- expect(subject.summary).to include('devoured')
-
- activity_title = subject.activity[:title]
-
- expect(activity_title).not_to include('opened')
- expect(activity_title).to include('devoured')
- end
- end
- end
-
context 'without markdown' do
let(:color) { '#345' }
@@ -106,4 +89,56 @@ RSpec.describe ChatMessage::MergeMessage do
end
end
end
+
+ context 'approved' do
+ before do
+ args[:object_attributes][:action] = 'approved'
+ end
+
+ it 'returns a message regarding completed approval of merge requests' do
+ expect(subject.pretext).to eq(
+ 'Test User (test.user) approved merge request <http://somewhere.com/-/merge_requests/100|!100 *Merge Request title*> '\
+ 'in <http://somewhere.com|project_name>')
+ expect(subject.attachments).to be_empty
+ end
+ end
+
+ context 'unapproved' do
+ before do
+ args[:object_attributes][:action] = 'unapproved'
+ end
+
+ it 'returns a message regarding revocation of completed approval of merge requests' do
+ expect(subject.pretext).to eq(
+ 'Test User (test.user) unapproved merge request <http://somewhere.com/-/merge_requests/100|!100 *Merge Request title*> '\
+ 'in <http://somewhere.com|project_name>')
+ expect(subject.attachments).to be_empty
+ end
+ end
+
+ context 'approval' do
+ before do
+ args[:object_attributes][:action] = 'approval'
+ end
+
+ it 'returns a message regarding added approval of merge requests' do
+ expect(subject.pretext).to eq(
+ 'Test User (test.user) added their approval to merge request <http://somewhere.com/-/merge_requests/100|!100 *Merge Request title*> '\
+ 'in <http://somewhere.com|project_name>')
+ expect(subject.attachments).to be_empty
+ end
+ end
+
+ context 'unapproval' do
+ before do
+ args[:object_attributes][:action] = 'unapproval'
+ end
+
+ it 'returns a message regarding revoking approval of merge requests' do
+ expect(subject.pretext).to eq(
+ 'Test User (test.user) removed their approval from merge request <http://somewhere.com/-/merge_requests/100|!100 *Merge Request title*> '\
+ 'in <http://somewhere.com|project_name>')
+ expect(subject.attachments).to be_empty
+ end
+ end
end
diff --git a/spec/models/project_services/ewm_service_spec.rb b/spec/models/project_services/ewm_service_spec.rb
new file mode 100644
index 00000000000..311c456569e
--- /dev/null
+++ b/spec/models/project_services/ewm_service_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe EwmService do
+ describe 'Associations' do
+ it { is_expected.to belong_to :project }
+ it { is_expected.to have_one :service_hook }
+ end
+
+ describe 'Validations' do
+ context 'when service is active' do
+ before do
+ subject.active = true
+ end
+
+ it { is_expected.to validate_presence_of(:project_url) }
+ it { is_expected.to validate_presence_of(:issues_url) }
+ it { is_expected.to validate_presence_of(:new_issue_url) }
+ it_behaves_like 'issue tracker service URL attribute', :project_url
+ it_behaves_like 'issue tracker service URL attribute', :issues_url
+ it_behaves_like 'issue tracker service URL attribute', :new_issue_url
+ end
+
+ context 'when service is inactive' do
+ before do
+ subject.active = false
+ end
+
+ it { is_expected.not_to validate_presence_of(:project_url) }
+ it { is_expected.not_to validate_presence_of(:issues_url) }
+ it { is_expected.not_to validate_presence_of(:new_issue_url) }
+ end
+ end
+
+ describe "ReferencePatternValidation" do
+ it "extracts bug" do
+ expect(described_class.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")
+ end
+
+ it "extracts work item" do
+ expect(described_class.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")
+ end
+
+ it "extracts defect" do
+ expect(described_class.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")
+ end
+ end
+end
diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb
index 28bba893be4..7741fea8717 100644
--- a/spec/models/project_services/jira_service_spec.rb
+++ b/spec/models/project_services/jira_service_spec.rb
@@ -10,6 +10,11 @@ RSpec.describe JiraService do
let(:username) { 'jira-username' }
let(:password) { 'jira-password' }
let(:transition_id) { 'test27' }
+ let(:server_info_results) { { 'deploymentType' => 'Cloud' } }
+
+ before do
+ WebMock.stub_request(:get, /serverInfo/).to_return(body: server_info_results.to_json )
+ end
describe '#options' do
let(:options) do
@@ -23,7 +28,7 @@ RSpec.describe JiraService do
}
end
- let(:service) { described_class.create(options) }
+ let(:service) { described_class.create!(options) }
it 'sets the URL properly' do
# jira-ruby gem parses the URI and handles trailing slashes fine:
@@ -97,13 +102,13 @@ RSpec.describe JiraService do
}
end
- subject { described_class.create(params) }
+ subject { described_class.create!(params) }
it 'does not store data into properties' do
expect(subject.properties).to be_nil
end
- it 'stores data in data_fields correcty' do
+ it 'stores data in data_fields correctly' do
service = subject
expect(service.jira_tracker_data.url).to eq(url)
@@ -111,6 +116,35 @@ RSpec.describe JiraService do
expect(service.jira_tracker_data.username).to eq(username)
expect(service.jira_tracker_data.password).to eq(password)
expect(service.jira_tracker_data.jira_issue_transition_id).to eq(transition_id)
+ expect(service.jira_tracker_data.deployment_cloud?).to be_truthy
+ end
+
+ context 'when loading serverInfo' do
+ let!(:jira_service) { subject }
+
+ context 'Cloud instance' do
+ let(:server_info_results) { { 'deploymentType' => 'Cloud' } }
+
+ it 'is detected' do
+ expect(jira_service.jira_tracker_data.deployment_cloud?).to be_truthy
+ end
+ end
+
+ context 'Server instance' do
+ let(:server_info_results) { { 'deploymentType' => 'Server' } }
+
+ it 'is detected' do
+ expect(jira_service.jira_tracker_data.deployment_server?).to be_truthy
+ end
+ end
+
+ context 'Unknown instance' do
+ let(:server_info_results) { { 'deploymentType' => 'FutureCloud' } }
+
+ it 'is detected' do
+ expect(jira_service.jira_tracker_data.deployment_unknown?).to be_truthy
+ end
+ end
end
end
@@ -151,11 +185,11 @@ RSpec.describe JiraService do
describe '#update' do
context 'basic update' do
- let(:new_username) { 'new_username' }
- let(:new_url) { 'http://jira-new.example.com' }
+ let_it_be(:new_username) { 'new_username' }
+ let_it_be(:new_url) { 'http://jira-new.example.com' }
before do
- service.update(username: new_username, url: new_url)
+ service.update!(username: new_username, url: new_url)
end
it 'leaves properties field emtpy' do
@@ -173,6 +207,63 @@ RSpec.describe JiraService do
end
end
+ context 'when updating the url, api_url, username, or password' do
+ it 'updates deployment type' do
+ service.update!(url: 'http://first.url')
+ service.jira_tracker_data.update!(deployment_type: 'server')
+
+ expect(service.jira_tracker_data.deployment_server?).to be_truthy
+
+ service.update!(api_url: 'http://another.url')
+ service.jira_tracker_data.reload
+
+ expect(service.jira_tracker_data.deployment_cloud?).to be_truthy
+ expect(WebMock).to have_requested(:get, /serverInfo/).twice
+ end
+
+ it 'calls serverInfo for url' do
+ service.update!(url: 'http://first.url')
+
+ expect(WebMock).to have_requested(:get, /serverInfo/)
+ end
+
+ it 'calls serverInfo for api_url' do
+ service.update!(api_url: 'http://another.url')
+
+ expect(WebMock).to have_requested(:get, /serverInfo/)
+ end
+
+ it 'calls serverInfo for username' do
+ service.update!(username: 'test-user')
+
+ expect(WebMock).to have_requested(:get, /serverInfo/)
+ end
+
+ it 'calls serverInfo for password' do
+ service.update!(password: 'test-password')
+
+ expect(WebMock).to have_requested(:get, /serverInfo/)
+ end
+ end
+
+ context 'when not updating the url, api_url, username, or password' do
+ it 'does not update deployment type' do
+ expect {service.update!(jira_issue_transition_id: 'jira_issue_transition_id')}.to raise_error(ActiveRecord::RecordInvalid)
+
+ expect(WebMock).not_to have_requested(:get, /serverInfo/)
+ end
+ end
+
+ context 'when not allowed to test an instance or group' do
+ it 'does not update deployment type' do
+ allow(service).to receive(:can_test?).and_return(false)
+
+ service.update!(url: 'http://first.url')
+
+ expect(WebMock).not_to have_requested(:get, /serverInfo/)
+ end
+ end
+
context 'stored password invalidation' do
context 'when a password was previously set' do
context 'when only web url present' do
@@ -187,7 +278,7 @@ RSpec.describe JiraService do
it 'resets password if url changed' do
service
service.url = 'http://jira_edited.example.com'
- service.save
+ service.save!
expect(service.reload.url).to eq('http://jira_edited.example.com')
expect(service.password).to be_nil
@@ -195,7 +286,7 @@ RSpec.describe JiraService do
it 'does not reset password if url "changed" to the same url as before' do
service.url = 'http://jira.example.com'
- service.save
+ service.save!
expect(service.reload.url).to eq('http://jira.example.com')
expect(service.password).not_to be_nil
@@ -203,7 +294,7 @@ RSpec.describe JiraService do
it 'resets password if url not changed but api url added' do
service.api_url = 'http://jira_edited.example.com/rest/api/2'
- service.save
+ service.save!
expect(service.reload.api_url).to eq('http://jira_edited.example.com/rest/api/2')
expect(service.password).to be_nil
@@ -212,7 +303,7 @@ RSpec.describe JiraService do
it 'does not reset password if new url is set together with password, even if it\'s the same password' do
service.url = 'http://jira_edited.example.com'
service.password = password
- service.save
+ service.save!
expect(service.password).to eq(password)
expect(service.url).to eq('http://jira_edited.example.com')
@@ -221,14 +312,14 @@ RSpec.describe JiraService do
it 'resets password if url changed, even if setter called multiple times' do
service.url = 'http://jira1.example.com/rest/api/2'
service.url = 'http://jira1.example.com/rest/api/2'
- service.save
+ service.save!
expect(service.password).to be_nil
end
it 'does not reset password if username changed' do
service.username = 'some_name'
- service.save
+ service.save!
expect(service.reload.password).to eq(password)
end
@@ -236,7 +327,7 @@ RSpec.describe JiraService do
it 'does not reset password if password changed' do
service.url = 'http://jira_edited.example.com'
service.password = 'new_password'
- service.save
+ service.save!
expect(service.reload.password).to eq('new_password')
end
@@ -244,7 +335,7 @@ RSpec.describe JiraService do
it 'does not reset password if the password is touched and same as before' do
service.url = 'http://jira_edited.example.com'
service.password = password
- service.save
+ service.save!
expect(service.reload.password).to eq(password)
end
@@ -261,20 +352,20 @@ RSpec.describe JiraService do
it 'resets password if api url changed' do
service.api_url = 'http://jira_edited.example.com/rest/api/2'
- service.save
+ service.save!
expect(service.password).to be_nil
end
it 'does not reset password if url changed' do
service.url = 'http://jira_edited.example.com'
- service.save
+ service.save!
expect(service.password).to eq(password)
end
it 'resets password if api url set to empty' do
- service.update(api_url: '')
+ service.update!(api_url: '')
expect(service.reload.password).to be_nil
end
@@ -291,7 +382,7 @@ RSpec.describe JiraService do
it 'saves password if new url is set together with password' do
service.url = 'http://jira_edited.example.com/rest/api/2'
service.password = 'password'
- service.save
+ service.save!
expect(service.reload.password).to eq('password')
expect(service.reload.url).to eq('http://jira_edited.example.com/rest/api/2')
end
@@ -360,7 +451,7 @@ RSpec.describe JiraService do
allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return('JIRA-123')
allow(JIRA::Resource::Remotelink).to receive(:all).and_return([])
- @jira_service.save
+ @jira_service.save!
project_issues_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123'
@transitions_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/transitions'
@@ -627,32 +718,32 @@ RSpec.describe JiraService do
end
describe '#test' do
+ let(:server_info_results) { { 'url' => 'http://url', 'deploymentType' => 'Cloud' } }
+ let_it_be(:project) { create(:project, :repository) }
let(:jira_service) do
described_class.new(
url: url,
+ project: project,
username: username,
password: password
)
end
- def test_settings(url = 'jira.example.com')
- test_url = "http://#{url}/rest/api/2/serverInfo"
-
- WebMock.stub_request(:get, test_url).with(basic_auth: [username, password])
- .to_return(body: { url: 'http://url' }.to_json )
-
+ def server_info
jira_service.test(nil)
end
context 'when the test succeeds' do
it 'gets Jira project with URL when API URL not set' do
- expect(test_settings).to eq(success: true, result: { 'url' => 'http://url' })
+ expect(server_info).to eq(success: true, result: server_info_results)
+ expect(WebMock).to have_requested(:get, /jira.example.com/)
end
it 'gets Jira project with API URL if set' do
- jira_service.update(api_url: 'http://jira.api.com')
+ jira_service.update!(api_url: 'http://jira.api.com')
- expect(test_settings('jira.api.com')).to eq(success: true, result: { 'url' => 'http://url' })
+ expect(server_info).to eq(success: true, result: server_info_results)
+ expect(WebMock).to have_requested(:get, /jira.api.com/)
end
end
diff --git a/spec/models/project_services/packagist_service_spec.rb b/spec/models/project_services/packagist_service_spec.rb
index f710385b6e2..33b5c9809c7 100644
--- a/spec/models/project_services/packagist_service_spec.rb
+++ b/spec/models/project_services/packagist_service_spec.rb
@@ -3,20 +3,6 @@
require 'spec_helper'
RSpec.describe PackagistService do
- describe "Associations" do
- it { is_expected.to belong_to :project }
- it { is_expected.to have_one :service_hook }
- end
-
- let(:project) { create(:project) }
-
- let(:packagist_server) { 'https://packagist.example.com' }
- let(:packagist_username) { 'theUser' }
- let(:packagist_token) { 'verySecret' }
- let(:packagist_hook_url) do
- "#{packagist_server}/api/update-package?username=#{packagist_username}&apiToken=#{packagist_token}"
- end
-
let(:packagist_params) do
{
active: true,
@@ -29,11 +15,25 @@ RSpec.describe PackagistService do
}
end
+ let(:packagist_hook_url) do
+ "#{packagist_server}/api/update-package?username=#{packagist_username}&apiToken=#{packagist_token}"
+ end
+
+ let(:packagist_token) { 'verySecret' }
+ let(:packagist_username) { 'theUser' }
+ let(:packagist_server) { 'https://packagist.example.com' }
+ let(:project) { create(:project) }
+
+ describe "Associations" do
+ it { is_expected.to belong_to :project }
+ it { is_expected.to have_one :service_hook }
+ end
+
describe '#execute' do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:push_sample_data) { Gitlab::DataBuilder::Push.build_sample(project, user) }
- let(:packagist_service) { described_class.create(packagist_params) }
+ let(:packagist_service) { described_class.create!(packagist_params) }
before do
stub_request(:post, packagist_hook_url)
diff --git a/spec/models/project_services/pipelines_email_service_spec.rb b/spec/models/project_services/pipelines_email_service_spec.rb
index 9a8386c619e..21cc5d44558 100644
--- a/spec/models/project_services/pipelines_email_service_spec.rb
+++ b/spec/models/project_services/pipelines_email_service_spec.rb
@@ -81,7 +81,7 @@ RSpec.describe PipelinesEmailService, :mailer do
context 'when pipeline is succeeded' do
before do
data[:object_attributes][:status] = 'success'
- pipeline.update(status: 'success')
+ pipeline.update!(status: 'success')
end
it_behaves_like 'sending email'
@@ -91,7 +91,7 @@ RSpec.describe PipelinesEmailService, :mailer do
context 'on default branch' do
before do
data[:object_attributes][:ref] = project.default_branch
- pipeline.update(ref: project.default_branch)
+ pipeline.update!(ref: project.default_branch)
end
context 'notifications are enabled only for default branch' do
@@ -115,7 +115,7 @@ RSpec.describe PipelinesEmailService, :mailer do
before do
create(:protected_branch, project: project, name: 'a-protected-branch')
data[:object_attributes][:ref] = 'a-protected-branch'
- pipeline.update(ref: 'a-protected-branch')
+ pipeline.update!(ref: 'a-protected-branch')
end
context 'notifications are enabled only for default branch' do
@@ -138,7 +138,7 @@ RSpec.describe PipelinesEmailService, :mailer do
context 'on a neither protected nor default branch' do
before do
data[:object_attributes][:ref] = 'a-random-branch'
- pipeline.update(ref: 'a-random-branch')
+ pipeline.update!(ref: 'a-random-branch')
end
context 'notifications are enabled only for default branch' do
@@ -177,7 +177,7 @@ RSpec.describe PipelinesEmailService, :mailer do
context 'with succeeded pipeline' do
before do
data[:object_attributes][:status] = 'success'
- pipeline.update(status: 'success')
+ pipeline.update!(status: 'success')
end
it_behaves_like 'not sending email'
@@ -195,7 +195,7 @@ RSpec.describe PipelinesEmailService, :mailer do
context 'with succeeded pipeline' do
before do
data[:object_attributes][:status] = 'success'
- pipeline.update(status: 'success')
+ pipeline.update!(status: 'success')
end
it_behaves_like 'not sending email'
@@ -206,7 +206,7 @@ RSpec.describe PipelinesEmailService, :mailer do
context 'on default branch' do
before do
data[:object_attributes][:ref] = project.default_branch
- pipeline.update(ref: project.default_branch)
+ pipeline.update!(ref: project.default_branch)
end
context 'notifications are enabled only for default branch' do
@@ -230,7 +230,7 @@ RSpec.describe PipelinesEmailService, :mailer do
before do
create(:protected_branch, project: project, name: 'a-protected-branch')
data[:object_attributes][:ref] = 'a-protected-branch'
- pipeline.update(ref: 'a-protected-branch')
+ pipeline.update!(ref: 'a-protected-branch')
end
context 'notifications are enabled only for default branch' do
@@ -253,7 +253,7 @@ RSpec.describe PipelinesEmailService, :mailer do
context 'on a neither protected nor default branch' do
before do
data[:object_attributes][:ref] = 'a-random-branch'
- pipeline.update(ref: 'a-random-branch')
+ pipeline.update!(ref: 'a-random-branch')
end
context 'notifications are enabled only for default branch' do
@@ -281,7 +281,7 @@ RSpec.describe PipelinesEmailService, :mailer do
context 'with failed pipeline' do
before do
data[:object_attributes][:status] = 'failed'
- pipeline.update(status: 'failed')
+ pipeline.update!(status: 'failed')
end
it_behaves_like 'not sending email'
@@ -295,7 +295,7 @@ RSpec.describe PipelinesEmailService, :mailer do
context 'with failed pipeline' do
before do
data[:object_attributes][:status] = 'failed'
- pipeline.update(status: 'failed')
+ pipeline.update!(status: 'failed')
end
it_behaves_like 'sending email'
diff --git a/spec/models/project_services/teamcity_service_spec.rb b/spec/models/project_services/teamcity_service_spec.rb
index a3fda33664a..f71dad86a08 100644
--- a/spec/models/project_services/teamcity_service_spec.rb
+++ b/spec/models/project_services/teamcity_service_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe TeamcityService, :use_clean_rails_memory_store_caching do
let(:project) { create(:project) }
subject(:service) do
- described_class.create(
+ described_class.create!(
project: project,
properties: {
teamcity_url: teamcity_url,
@@ -85,7 +85,7 @@ RSpec.describe TeamcityService, :use_clean_rails_memory_store_caching do
teamcity_service = service
teamcity_service.teamcity_url = 'http://gitlab1.com'
- teamcity_service.save
+ teamcity_service.save!
expect(teamcity_service.password).to be_nil
end
@@ -94,7 +94,7 @@ RSpec.describe TeamcityService, :use_clean_rails_memory_store_caching do
teamcity_service = service
teamcity_service.username = 'some_name'
- teamcity_service.save
+ teamcity_service.save!
expect(teamcity_service.password).to eq('password')
end
@@ -104,7 +104,7 @@ RSpec.describe TeamcityService, :use_clean_rails_memory_store_caching do
teamcity_service.teamcity_url = 'http://gitlab_edited.com'
teamcity_service.password = 'password'
- teamcity_service.save
+ teamcity_service.save!
expect(teamcity_service.password).to eq('password')
expect(teamcity_service.teamcity_url).to eq('http://gitlab_edited.com')
@@ -117,7 +117,7 @@ RSpec.describe TeamcityService, :use_clean_rails_memory_store_caching do
teamcity_service.teamcity_url = 'http://gitlab_edited.com'
teamcity_service.password = 'password'
- teamcity_service.save
+ teamcity_service.save!
expect(teamcity_service.password).to eq('password')
expect(teamcity_service.teamcity_url).to eq('http://gitlab_edited.com')
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index f589589af8f..fe971832695 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -61,6 +61,7 @@ RSpec.describe Project do
it { is_expected.to have_one(:youtrack_service) }
it { is_expected.to have_one(:custom_issue_tracker_service) }
it { is_expected.to have_one(:bugzilla_service) }
+ it { is_expected.to have_one(:ewm_service) }
it { is_expected.to have_one(:external_wiki_service) }
it { is_expected.to have_one(:confluence_service) }
it { is_expected.to have_one(:project_feature) }
@@ -84,7 +85,6 @@ RSpec.describe Project do
it { is_expected.to have_many(:runners) }
it { is_expected.to have_many(:variables) }
it { is_expected.to have_many(:triggers) }
- it { is_expected.to have_many(:pages_domains) }
it { is_expected.to have_many(:labels).class_name('ProjectLabel') }
it { is_expected.to have_many(:users_star_projects) }
it { is_expected.to have_many(:repository_languages) }
@@ -124,6 +124,11 @@ RSpec.describe Project do
it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') }
it { is_expected.to have_many(:pipeline_artifacts) }
+ # GitLab Pages
+ it { is_expected.to have_many(:pages_domains) }
+ it { is_expected.to have_one(:pages_metadatum) }
+ it { is_expected.to have_many(:pages_deployments) }
+
it_behaves_like 'model with repository' do
let_it_be(:container) { create(:project, :repository, path: 'somewhere') }
let(:stubbed_container) { build_stubbed(:project) }
@@ -131,7 +136,7 @@ RSpec.describe Project do
end
it_behaves_like 'model with wiki' do
- let(:container) { create(:project, :wiki_repo) }
+ let_it_be(:container) { create(:project, :wiki_repo) }
let(:container_without_wiki) { create(:project) }
end
@@ -202,11 +207,11 @@ RSpec.describe Project do
end
describe '#members & #requesters' do
- let(:project) { create(:project, :public) }
- let(:requester) { create(:user) }
- let(:developer) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:requester) { create(:user) }
+ let_it_be(:developer) { create(:user) }
- before do
+ before_all do
project.request_access(requester)
project.add_developer(developer)
end
@@ -453,9 +458,9 @@ RSpec.describe Project do
end
describe '#all_pipelines' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
- before do
+ before_all do
create(:ci_pipeline, project: project, ref: 'master', source: :web)
create(:ci_pipeline, project: project, ref: 'master', source: :external)
end
@@ -477,7 +482,7 @@ RSpec.describe Project do
end
describe '#has_packages?' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
subject { project.has_packages?(package_type) }
@@ -517,14 +522,15 @@ RSpec.describe Project do
end
describe '#ci_pipelines' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
- before do
+ before_all do
create(:ci_pipeline, project: project, ref: 'master', source: :web)
create(:ci_pipeline, project: project, ref: 'master', source: :external)
+ create(:ci_pipeline, project: project, ref: 'master', source: :webide)
end
- it 'has ci pipelines' do
+ it 'excludes dangling pipelines such as :webide' do
expect(project.ci_pipelines.size).to eq(2)
end
@@ -542,7 +548,7 @@ RSpec.describe Project do
describe '#autoclose_referenced_issues' do
context 'when DB entry is nil' do
- let(:project) { create(:project, autoclose_referenced_issues: nil) }
+ let(:project) { build(:project, autoclose_referenced_issues: nil) }
it 'returns true' do
expect(project.autoclose_referenced_issues).to be_truthy
@@ -550,7 +556,7 @@ RSpec.describe Project do
end
context 'when DB entry is true' do
- let(:project) { create(:project, autoclose_referenced_issues: true) }
+ let(:project) { build(:project, autoclose_referenced_issues: true) }
it 'returns true' do
expect(project.autoclose_referenced_issues).to be_truthy
@@ -558,7 +564,7 @@ RSpec.describe Project do
end
context 'when DB entry is false' do
- let(:project) { create(:project, autoclose_referenced_issues: false) }
+ let(:project) { build(:project, autoclose_referenced_issues: false) }
it 'returns false' do
expect(project.autoclose_referenced_issues).to be_falsey
@@ -768,8 +774,8 @@ RSpec.describe Project do
end
describe "#new_issuable_address" do
- let(:project) { create(:project, path: "somewhere") }
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, path: "somewhere") }
+ let_it_be(:user) { create(:user) }
context 'incoming email enabled' do
before do
@@ -850,11 +856,11 @@ RSpec.describe Project do
end
describe '#get_issue' do
- let(:project) { create(:project) }
- let!(:issue) { create(:issue, project: project) }
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let!(:issue) { create(:issue, project: project) }
- before do
+ before_all do
project.add_developer(user)
end
@@ -926,7 +932,7 @@ RSpec.describe Project do
end
describe '#issue_exists?' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
it 'is truthy when issue exists' do
expect(project).to receive(:get_issue).and_return(double)
@@ -1019,7 +1025,7 @@ RSpec.describe Project do
end
describe '#cache_has_external_issue_tracker' do
- let(:project) { create(:project, has_external_issue_tracker: nil) }
+ let_it_be(:project) { create(:project, has_external_issue_tracker: nil) }
it 'stores true if there is any external_issue_tracker' do
services = double(:service, external_issue_trackers: [RedmineService.new])
@@ -1049,7 +1055,7 @@ RSpec.describe Project do
end
describe '#cache_has_external_wiki' do
- let(:project) { create(:project, has_external_wiki: nil) }
+ let_it_be(:project) { create(:project, has_external_wiki: nil) }
it 'stores true if there is any external_wikis' do
services = double(:service, external_wikis: [ExternalWikiService.new])
@@ -1115,7 +1121,7 @@ RSpec.describe Project do
end
describe '#external_wiki' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
context 'with an active external wiki' do
before do
@@ -1268,60 +1274,6 @@ RSpec.describe Project do
end
end
- describe '#pipeline_for' do
- let(:project) { create(:project, :repository) }
-
- shared_examples 'giving the correct pipeline' do
- it { is_expected.to eq(pipeline) }
-
- context 'return latest' do
- let!(:pipeline2) { create_pipeline(project) }
-
- it { is_expected.to eq(pipeline2) }
- end
- end
-
- context 'with a matching pipeline' do
- let!(:pipeline) { create_pipeline(project) }
-
- context 'with explicit sha' do
- subject { project.pipeline_for('master', pipeline.sha) }
-
- it_behaves_like 'giving the correct pipeline'
-
- context 'with supplied id' do
- let!(:other_pipeline) { create_pipeline(project) }
-
- subject { project.pipeline_for('master', pipeline.sha, other_pipeline.id) }
-
- it { is_expected.to eq(other_pipeline) }
- end
- end
-
- context 'with implicit sha' do
- subject { project.pipeline_for('master') }
-
- it_behaves_like 'giving the correct pipeline'
- end
- end
-
- context 'when there is no matching pipeline' do
- subject { project.pipeline_for('master') }
-
- it { is_expected.to be_nil }
- end
- end
-
- describe '#pipelines_for' do
- let(:project) { create(:project, :repository) }
- let!(:pipeline) { create_pipeline(project) }
- let!(:other_pipeline) { create_pipeline(project) }
-
- subject { project.pipelines_for(project.default_branch, project.commit.sha) }
-
- it { is_expected.to contain_exactly(pipeline, other_pipeline) }
- end
-
describe '#builds_enabled' do
let(:project) { create(:project) }
@@ -1362,6 +1314,36 @@ RSpec.describe Project do
end
end
+ describe '.with_active_jira_services' do
+ it 'returns the correct project' do
+ active_jira_service = create(:jira_service)
+ active_service = create(:service, active: true)
+
+ expect(described_class.with_active_jira_services).to include(active_jira_service.project)
+ expect(described_class.with_active_jira_services).not_to include(active_service.project)
+ end
+ end
+
+ describe '.with_jira_dvcs_cloud' do
+ it 'returns the correct project' do
+ jira_dvcs_cloud_project = create(:project, :jira_dvcs_cloud)
+ jira_dvcs_server_project = create(:project, :jira_dvcs_server)
+
+ expect(described_class.with_jira_dvcs_cloud).to include(jira_dvcs_cloud_project)
+ expect(described_class.with_jira_dvcs_cloud).not_to include(jira_dvcs_server_project)
+ end
+ end
+
+ describe '.with_jira_dvcs_server' do
+ it 'returns the correct project' do
+ jira_dvcs_server_project = create(:project, :jira_dvcs_server)
+ jira_dvcs_cloud_project = create(:project, :jira_dvcs_cloud)
+
+ expect(described_class.with_jira_dvcs_server).to include(jira_dvcs_server_project)
+ expect(described_class.with_jira_dvcs_server).not_to include(jira_dvcs_cloud_project)
+ end
+ end
+
describe '.cached_count', :use_clean_rails_memory_store_caching do
let(:group) { create(:group, :public) }
let!(:project1) { create(:project, :public, group: group) }
@@ -1759,7 +1741,7 @@ RSpec.describe Project do
end
describe '#visibility_level_allowed?' do
- let(:project) { create(:project, :internal) }
+ let_it_be(:project) { create(:project, :internal) }
context 'when checking on non-forked project' do
it { expect(project.visibility_level_allowed?(Gitlab::VisibilityLevel::PRIVATE)).to be_truthy }
@@ -1768,7 +1750,6 @@ RSpec.describe Project do
end
context 'when checking on forked project' do
- let(:project) { create(:project, :internal) }
let(:forked_project) { fork_project(project) }
it { expect(forked_project.visibility_level_allowed?(Gitlab::VisibilityLevel::PRIVATE)).to be_truthy }
@@ -1953,7 +1934,7 @@ RSpec.describe Project do
end
describe '.optionally_search' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
it 'searches for projects matching the query if one is given' do
relation = described_class.optionally_search(project.name)
@@ -2010,7 +1991,7 @@ RSpec.describe Project do
end
describe '.search_by_title' do
- let(:project) { create(:project, name: 'kittens') }
+ let_it_be(:project) { create(:project, name: 'kittens') }
it 'returns projects with a matching name' do
expect(described_class.search_by_title(project.name)).to eq([project])
@@ -2026,11 +2007,11 @@ RSpec.describe Project do
end
context 'when checking projects from groups' do
- let(:private_group) { create(:group, visibility_level: 0) }
- let(:internal_group) { create(:group, visibility_level: 10) }
+ let(:private_group) { build(:group, visibility_level: 0) }
+ let(:internal_group) { build(:group, visibility_level: 10) }
- let(:private_project) { create(:project, :private, group: private_group) }
- let(:internal_project) { create(:project, :internal, group: internal_group) }
+ let(:private_project) { build(:project, :private, group: private_group) }
+ let(:internal_project) { build(:project, :internal, group: internal_group) }
context 'when group is private project can not be internal' do
it { expect(private_project.visibility_level_allowed?(Gitlab::VisibilityLevel::INTERNAL)).to be_falsey }
@@ -2094,7 +2075,7 @@ RSpec.describe Project do
end
describe '#create_repository' do
- let(:project) { create(:project, :repository) }
+ let_it_be(:project) { build(:project, :repository) }
context 'using a regular repository' do
it 'creates the repository' do
@@ -2120,7 +2101,7 @@ RSpec.describe Project do
end
describe '#ensure_repository' do
- let(:project) { create(:project, :repository) }
+ let_it_be(:project) { build(:project, :repository) }
it 'creates the repository if it not exist' do
allow(project).to receive(:repository_exists?).and_return(false)
@@ -2174,7 +2155,7 @@ RSpec.describe Project do
end
describe '#container_registry_url' do
- let(:project) { create(:project) }
+ let_it_be(:project) { build(:project) }
subject { project.container_registry_url }
@@ -2201,7 +2182,7 @@ RSpec.describe Project do
end
describe '#has_container_registry_tags?' do
- let(:project) { create(:project) }
+ let(:project) { build(:project) }
context 'when container registry is enabled' do
before do
@@ -2267,7 +2248,7 @@ RSpec.describe Project do
describe '#ci_config_path=' do
using RSpec::Parameterized::TableSyntax
- let(:project) { create(:project) }
+ let(:project) { build_stubbed(:project) }
where(:default_ci_config_path, :project_ci_config_path, :expected_ci_config_path) do
nil | :notset | :default
@@ -2322,8 +2303,8 @@ RSpec.describe Project do
end
describe '#latest_successful_build_for_ref' do
- let(:project) { create(:project, :repository) }
- let(:pipeline) { create_pipeline(project) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:pipeline) { create_pipeline(project) }
it_behaves_like 'latest successful build for sha or ref'
@@ -2338,47 +2319,95 @@ RSpec.describe Project do
end
end
- describe '#latest_pipeline_for_ref' do
- let(:project) { create(:project, :repository) }
+ describe '#latest_pipeline' do
+ let_it_be(:project) { create(:project, :repository) }
let(:second_branch) { project.repository.branches[2] }
let!(:pipeline_for_default_branch) do
- create(:ci_empty_pipeline, project: project, sha: project.commit.id,
- ref: project.default_branch)
+ create(:ci_pipeline, project: project, sha: project.commit.id,
+ ref: project.default_branch)
end
let!(:pipeline_for_second_branch) do
- create(:ci_empty_pipeline, project: project, sha: second_branch.target,
- ref: second_branch.name)
+ create(:ci_pipeline, project: project, sha: second_branch.target,
+ ref: second_branch.name)
end
- before do
- create(:ci_empty_pipeline, project: project, sha: project.commit.parent.id,
- ref: project.default_branch)
+ let!(:other_pipeline_for_default_branch) do
+ create(:ci_pipeline, project: project, sha: project.commit.parent.id,
+ ref: project.default_branch)
end
context 'default repository branch' do
- subject { project.latest_pipeline_for_ref(project.default_branch) }
+ context 'when explicitly provided' do
+ subject { project.latest_pipeline(project.default_branch) }
+
+ it { is_expected.to eq(pipeline_for_default_branch) }
+ end
+
+ context 'when not provided' do
+ subject { project.latest_pipeline }
+
+ it { is_expected.to eq(pipeline_for_default_branch) }
+ end
+
+ context 'with provided sha' do
+ subject { project.latest_pipeline(project.default_branch, project.commit.parent.id) }
- it { is_expected.to eq(pipeline_for_default_branch) }
+ it { is_expected.to eq(other_pipeline_for_default_branch) }
+ end
end
context 'provided ref' do
- subject { project.latest_pipeline_for_ref(second_branch.name) }
+ subject { project.latest_pipeline(second_branch.name) }
it { is_expected.to eq(pipeline_for_second_branch) }
+
+ context 'with provided sha' do
+ let!(:latest_pipeline_for_ref) do
+ create(:ci_pipeline, project: project, sha: pipeline_for_second_branch.sha,
+ ref: pipeline_for_second_branch.ref)
+ end
+
+ subject { project.latest_pipeline(second_branch.name, second_branch.target) }
+
+ it { is_expected.to eq(latest_pipeline_for_ref) }
+ end
end
context 'bad ref' do
- subject { project.latest_pipeline_for_ref(SecureRandom.uuid) }
+ before do
+ # ensure we don't skip the filter by ref by mistakenly return this pipeline
+ create(:ci_pipeline, project: project)
+ end
+
+ subject { project.latest_pipeline(SecureRandom.uuid) }
it { is_expected.to be_nil }
end
+
+ context 'on deleted ref' do
+ let(:branch) { project.repository.branches.last }
+
+ let!(:pipeline_on_deleted_ref) do
+ create(:ci_pipeline, project: project, sha: branch.target, ref: branch.name)
+ end
+
+ before do
+ project.repository.rm_branch(project.owner, branch.name)
+ end
+
+ subject { project.latest_pipeline(branch.name) }
+
+ it 'always returns nil despite a pipeline exists' do
+ expect(subject).to be_nil
+ end
+ end
end
describe '#latest_successful_build_for_sha' do
- let(:project) { create(:project, :repository) }
- let(:pipeline) { create_pipeline(project) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:pipeline) { create_pipeline(project) }
it_behaves_like 'latest successful build for sha or ref'
@@ -2386,8 +2415,8 @@ RSpec.describe Project do
end
describe '#latest_successful_build_for_ref!' do
- let(:project) { create(:project, :repository) }
- let(:pipeline) { create_pipeline(project) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:pipeline) { create_pipeline(project) }
context 'with many builds' do
it 'gives the latest builds from latest pipeline' do
@@ -2460,7 +2489,7 @@ RSpec.describe Project do
end
describe '#jira_import_status' do
- let(:project) { create(:project, import_type: 'jira') }
+ let_it_be(:project) { create(:project, import_type: 'jira') }
context 'when no jira imports' do
it 'returns none' do
@@ -2666,7 +2695,7 @@ RSpec.describe Project do
end
describe '#remote_mirror_available?' do
- let(:project) { create(:project) }
+ let(:project) { build_stubbed(:project) }
context 'when remote mirror global setting is enabled' do
it 'returns true' do
@@ -2707,10 +2736,10 @@ RSpec.describe Project do
end
describe '#ancestors_upto' do
- let(:parent) { create(:group) }
- let(:child) { create(:group, parent: parent) }
- let(:child2) { create(:group, parent: child) }
- let(:project) { create(:project, namespace: child2) }
+ let_it_be(:parent) { create(:group) }
+ let_it_be(:child) { create(:group, parent: parent) }
+ let_it_be(:child2) { create(:group, parent: child) }
+ let_it_be(:project) { create(:project, namespace: child2) }
it 'returns all ancestors when no namespace is given' do
expect(project.ancestors_upto).to contain_exactly(child2, child, parent)
@@ -2755,7 +2784,7 @@ RSpec.describe Project do
end
describe '#emails_disabled?' do
- let(:project) { create(:project, emails_disabled: false) }
+ let(:project) { build(:project, emails_disabled: false) }
context 'emails disabled in group' do
it 'returns true' do
@@ -2783,7 +2812,7 @@ RSpec.describe Project do
end
describe '#lfs_enabled?' do
- let(:project) { create(:project) }
+ let(:project) { build(:project) }
shared_examples 'project overrides group' do
it 'returns true when enabled in project' do
@@ -2845,7 +2874,7 @@ RSpec.describe Project do
end
describe '#change_head' do
- let(:project) { create(:project, :repository) }
+ let_it_be(:project) { create(:project, :repository) }
it 'returns error if branch does not exist' do
expect(project.change_head('unexisted-branch')).to be false
@@ -2876,6 +2905,20 @@ RSpec.describe Project do
end
end
+ describe '#lfs_objects_for_repository_types' do
+ let(:project) { create(:project) }
+
+ it 'returns LFS objects of the specified type only' do
+ none, design, wiki = *[nil, :design, :wiki].map do |type|
+ create(:lfs_objects_project, project: project, repository_type: type).lfs_object
+ end
+
+ expect(project.lfs_objects_for_repository_types(nil)).to contain_exactly(none)
+ expect(project.lfs_objects_for_repository_types(nil, :wiki)).to contain_exactly(none, wiki)
+ expect(project.lfs_objects_for_repository_types(:design)).to contain_exactly(design)
+ end
+ end
+
context 'forks' do
include ProjectForksHelper
@@ -2951,68 +2994,6 @@ RSpec.describe Project do
expect(project.forks).to contain_exactly(forked_project)
end
end
-
- describe '#lfs_storage_project' do
- it 'returns self for non-forks' do
- expect(project.lfs_storage_project).to eq project
- end
-
- it 'returns the fork network root for forks' do
- second_fork = fork_project(forked_project)
-
- expect(second_fork.lfs_storage_project).to eq project
- end
-
- it 'returns self when fork_source is nil' do
- expect(forked_project).to receive(:fork_source).and_return(nil)
-
- expect(forked_project.lfs_storage_project).to eq forked_project
- end
- end
-
- describe '#all_lfs_objects' do
- let(:lfs_object) { create(:lfs_object) }
-
- context 'when LFS object is only associated to the source' do
- before do
- project.lfs_objects << lfs_object
- end
-
- it 'returns the lfs object for a project' do
- expect(project.all_lfs_objects).to contain_exactly(lfs_object)
- end
-
- it 'returns the lfs object for a fork' do
- expect(forked_project.all_lfs_objects).to contain_exactly(lfs_object)
- end
- end
-
- context 'when LFS object is only associated to the fork' do
- before do
- forked_project.lfs_objects << lfs_object
- end
-
- it 'returns nothing' do
- expect(project.all_lfs_objects).to be_empty
- end
-
- it 'returns the lfs object for a fork' do
- expect(forked_project.all_lfs_objects).to contain_exactly(lfs_object)
- end
- end
-
- context 'when LFS object is associated to both source and fork' do
- before do
- project.lfs_objects << lfs_object
- forked_project.lfs_objects << lfs_object
- end
-
- it 'returns the lfs object for the source and fork' do
- expect(project.all_lfs_objects).to contain_exactly(lfs_object)
- expect(forked_project.all_lfs_objects).to contain_exactly(lfs_object)
- end
- end
- end
end
describe '#set_repository_read_only!' do
@@ -3040,7 +3021,7 @@ RSpec.describe Project do
end
describe '#pushes_since_gc' do
- let(:project) { create(:project) }
+ let(:project) { build_stubbed(:project) }
after do
project.reset_pushes_since_gc
@@ -3062,7 +3043,7 @@ RSpec.describe Project do
end
describe '#increment_pushes_since_gc' do
- let(:project) { create(:project) }
+ let(:project) { build_stubbed(:project) }
after do
project.reset_pushes_since_gc
@@ -3076,7 +3057,7 @@ RSpec.describe Project do
end
describe '#reset_pushes_since_gc' do
- let(:project) { create(:project) }
+ let(:project) { build_stubbed(:project) }
after do
project.reset_pushes_since_gc
@@ -3092,7 +3073,7 @@ RSpec.describe Project do
end
describe '#deployment_variables' do
- let(:project) { create(:project) }
+ let(:project) { build_stubbed(:project) }
let(:environment) { 'production' }
let(:namespace) { 'namespace' }
@@ -3169,7 +3150,7 @@ RSpec.describe Project do
end
describe '#default_environment' do
- let(:project) { create(:project) }
+ let(:project) { build(:project) }
it 'returns production environment when it exists' do
production = create(:environment, name: "production", project: project)
@@ -3191,7 +3172,7 @@ RSpec.describe Project do
end
describe '#ci_variables_for' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
let(:environment_scope) { '*' }
let!(:ci_variable) do
@@ -3346,7 +3327,7 @@ RSpec.describe Project do
end
describe '#ci_instance_variables_for' do
- let(:project) { create(:project) }
+ let(:project) { build_stubbed(:project) }
let!(:instance_variable) do
create(:ci_instance_variable, value: 'secret')
@@ -5831,32 +5812,57 @@ RSpec.describe Project do
end
end
- context 'pages deployed' do
+ describe '#mark_pages_as_deployed' do
let(:project) { create(:project) }
+ let(:artifacts_archive) { create(:ci_job_artifact, project: project) }
- {
- mark_pages_as_deployed: true,
- mark_pages_as_not_deployed: false
- }.each do |method_name, flag|
- describe method_name do
- it "creates new record and sets deployed to #{flag} if none exists yet" do
- project.pages_metadatum.destroy!
- project.reload
+ it "works when artifacts_archive is missing" do
+ project.mark_pages_as_deployed
- project.send(method_name)
+ expect(project.pages_metadatum.reload.deployed).to eq(true)
+ end
- expect(project.pages_metadatum.reload.deployed).to eq(flag)
- end
+ it "creates new record and sets deployed to true if none exists yet" do
+ project.pages_metadatum.destroy!
+ project.reload
- it "updates the existing record and sets deployed to #{flag}" do
- pages_metadatum = project.pages_metadatum
- pages_metadatum.update!(deployed: !flag)
+ project.mark_pages_as_deployed(artifacts_archive: artifacts_archive)
- expect { project.send(method_name) }.to change {
- pages_metadatum.reload.deployed
- }.from(!flag).to(flag)
- end
- end
+ expect(project.pages_metadatum.reload.deployed).to eq(true)
+ end
+
+ it "updates the existing record and sets deployed to true and records artifact archive" do
+ pages_metadatum = project.pages_metadatum
+ pages_metadatum.update!(deployed: false)
+
+ expect do
+ project.mark_pages_as_deployed(artifacts_archive: artifacts_archive)
+ end.to change { pages_metadatum.reload.deployed }.from(false).to(true)
+ .and change { pages_metadatum.reload.artifacts_archive }.from(nil).to(artifacts_archive)
+ end
+ end
+
+ describe '#mark_pages_as_not_deployed' do
+ let(:project) { create(:project) }
+ let(:artifacts_archive) { create(:ci_job_artifact, project: project) }
+
+ it "creates new record and sets deployed to false if none exists yet" do
+ project.pages_metadatum.destroy!
+ project.reload
+
+ project.mark_pages_as_not_deployed
+
+ expect(project.pages_metadatum.reload.deployed).to eq(false)
+ end
+
+ it "updates the existing record and sets deployed to false and clears artifacts_archive" do
+ pages_metadatum = project.pages_metadatum
+ pages_metadatum.update!(deployed: true, artifacts_archive: artifacts_archive)
+
+ expect do
+ project.mark_pages_as_not_deployed
+ end.to change { pages_metadatum.reload.deployed }.from(true).to(false)
+ .and change { pages_metadatum.reload.artifacts_archive }.from(artifacts_archive).to(nil)
end
end
@@ -6043,6 +6049,18 @@ RSpec.describe Project do
end
end
+ describe '#jira_subscription_exists?' do
+ let(:project) { create(:project) }
+
+ subject { project.jira_subscription_exists? }
+
+ context 'jira connect subscription exists' do
+ let!(:jira_connect_subscription) { create(:jira_connect_subscription, namespace: project.namespace) }
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
describe 'with services and chat names' do
subject { create(:project) }
@@ -6088,53 +6106,6 @@ RSpec.describe Project do
end
end
- describe '#all_lfs_objects_oids' do
- let(:project) { create(:project) }
- let(:lfs_object) { create(:lfs_object) }
- let(:another_lfs_object) { create(:lfs_object) }
-
- subject { project.all_lfs_objects_oids }
-
- context 'when project has associated LFS objects' do
- before do
- create(:lfs_objects_project, lfs_object: lfs_object, project: project)
- create(:lfs_objects_project, lfs_object: another_lfs_object, project: project)
- end
-
- it 'returns OIDs of LFS objects' do
- expect(subject).to match_array([lfs_object.oid, another_lfs_object.oid])
- end
-
- context 'and there are specified oids' do
- subject { project.all_lfs_objects_oids(oids: [lfs_object.oid]) }
-
- it 'returns OIDs of LFS objects that match specified oids' do
- expect(subject).to eq([lfs_object.oid])
- end
- end
- end
-
- context 'when fork has associated LFS objects to itself and source' do
- let(:source) { create(:project) }
- let(:project) { fork_project(source) }
-
- before do
- create(:lfs_objects_project, lfs_object: lfs_object, project: source)
- create(:lfs_objects_project, lfs_object: another_lfs_object, project: project)
- end
-
- it 'returns OIDs of LFS objects' do
- expect(subject).to match_array([lfs_object.oid, another_lfs_object.oid])
- end
- end
-
- context 'when project has no associated LFS objects' do
- it 'returns empty array' do
- expect(subject).to be_empty
- end
- end
- end
-
describe '#lfs_objects_oids' do
let(:project) { create(:project) }
let(:lfs_object) { create(:lfs_object) }
@@ -6475,6 +6446,49 @@ RSpec.describe Project do
end
end
+ describe '#enabled_group_deploy_keys' do
+ let_it_be(:project) { create(:project) }
+
+ subject { project.enabled_group_deploy_keys }
+
+ context 'when a project does not have a group' do
+ it { is_expected.to be_empty }
+ end
+
+ context 'when a project has a parent group' do
+ let!(:group) { create(:group, projects: [project]) }
+
+ context 'and this group has a group deploy key enabled' do
+ let!(:group_deploy_key) { create(:group_deploy_key, groups: [group]) }
+
+ it { is_expected.to contain_exactly(group_deploy_key) }
+
+ context 'and this group has parent group which also has a group deploy key enabled' do
+ let(:super_group) { create(:group) }
+
+ it 'returns both group deploy keys' do
+ super_group = create(:group)
+ super_group_deploy_key = create(:group_deploy_key, groups: [super_group])
+ group.update!(parent: super_group)
+
+ expect(subject).to contain_exactly(group_deploy_key, super_group_deploy_key)
+ end
+ end
+ end
+
+ context 'and another group has a group deploy key enabled' do
+ let_it_be(:group_deploy_key) { create(:group_deploy_key) }
+
+ it 'does not return this group deploy key' do
+ another_group = create(:group)
+ create(:group_deploy_key, groups: [another_group])
+
+ expect(subject).to be_empty
+ end
+ end
+ end
+ end
+
def finish_job(export_job)
export_job.start
export_job.finish
diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb
index 5f66de3a63c..383fabcfffb 100644
--- a/spec/models/project_statistics_spec.rb
+++ b/spec/models/project_statistics_spec.rb
@@ -32,8 +32,9 @@ RSpec.describe ProjectStatistics do
repository_size: 2.exabytes,
wiki_size: 1.exabytes,
lfs_objects_size: 2.exabytes,
- build_artifacts_size: 2.exabytes - 1,
- snippets_size: 1.exabyte
+ build_artifacts_size: 1.exabyte,
+ snippets_size: 1.exabyte,
+ pipeline_artifacts_size: 1.exabyte - 1
)
statistics.reload
@@ -42,9 +43,10 @@ RSpec.describe ProjectStatistics do
expect(statistics.repository_size).to eq(2.exabytes)
expect(statistics.wiki_size).to eq(1.exabytes)
expect(statistics.lfs_objects_size).to eq(2.exabytes)
- expect(statistics.build_artifacts_size).to eq(2.exabytes - 1)
+ expect(statistics.build_artifacts_size).to eq(1.exabyte)
expect(statistics.storage_size).to eq(8.exabytes - 1)
expect(statistics.snippets_size).to eq(1.exabyte)
+ expect(statistics.pipeline_artifacts_size).to eq(1.exabyte - 1)
end
end
@@ -282,12 +284,13 @@ RSpec.describe ProjectStatistics do
repository_size: 2,
wiki_size: 4,
lfs_objects_size: 3,
- snippets_size: 2
+ snippets_size: 2,
+ pipeline_artifacts_size: 3
)
statistics.reload
- expect(statistics.storage_size).to eq 11
+ expect(statistics.storage_size).to eq 14
end
it 'works during wiki_size backfill' do
@@ -339,6 +342,12 @@ RSpec.describe ProjectStatistics do
it_behaves_like 'a statistic that increases storage_size'
end
+ context 'when adjusting :pipeline_artifacts_size' do
+ let(:stat) { :pipeline_artifacts_size }
+
+ it_behaves_like 'a statistic that increases storage_size'
+ end
+
context 'when adjusting :packages_size' do
let(:stat) { :packages_size }
diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb
index 34ec856459c..bbc056889d6 100644
--- a/spec/models/project_team_spec.rb
+++ b/spec/models/project_team_spec.rb
@@ -3,6 +3,8 @@
require "spec_helper"
RSpec.describe ProjectTeam do
+ include ProjectForksHelper
+
let(:maintainer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
@@ -237,6 +239,35 @@ RSpec.describe ProjectTeam do
end
end
+ describe '#contributor?' do
+ let(:project) { create(:project, :public, :repository) }
+
+ context 'when user is a member of project' do
+ before do
+ project.add_maintainer(maintainer)
+ project.add_reporter(reporter)
+ project.add_guest(guest)
+ end
+
+ it { expect(project.team.contributor?(maintainer.id)).to be false }
+ it { expect(project.team.contributor?(reporter.id)).to be false }
+ it { expect(project.team.contributor?(guest.id)).to be false }
+ end
+
+ context 'when user has at least one merge request merged into default_branch' do
+ let(:contributor) { create(:user) }
+ let(:user_without_access) { create(:user) }
+ let(:first_fork_project) { fork_project(project, contributor, repository: true) }
+
+ before do
+ create(:merge_request, :merged, author: contributor, target_project: project, source_project: first_fork_project, target_branch: project.default_branch.to_s)
+ end
+
+ it { expect(project.team.contributor?(contributor.id)).to be true }
+ it { expect(project.team.contributor?(user_without_access.id)).to be false }
+ end
+ end
+
describe '#max_member_access' do
let(:requester) { create(:user) }
@@ -366,6 +397,66 @@ RSpec.describe ProjectTeam do
end
end
+ describe '#contribution_check_for_user_ids', :request_store do
+ let(:project) { create(:project, :public, :repository) }
+ let(:contributor) { create(:user) }
+ let(:second_contributor) { create(:user) }
+ let(:user_without_access) { create(:user) }
+ let(:first_fork_project) { fork_project(project, contributor, repository: true) }
+ let(:second_fork_project) { fork_project(project, second_contributor, repository: true) }
+
+ let(:users) do
+ [contributor, second_contributor, user_without_access].map(&:id)
+ end
+
+ let(:expected) do
+ {
+ contributor.id => true,
+ second_contributor.id => true,
+ user_without_access.id => false
+ }
+ end
+
+ before do
+ create(:merge_request, :merged, author: contributor, target_project: project, source_project: first_fork_project, target_branch: project.default_branch.to_s)
+ create(:merge_request, :merged, author: second_contributor, target_project: project, source_project: second_fork_project, target_branch: project.default_branch.to_s)
+ end
+
+ def contributors(users)
+ project.team.contribution_check_for_user_ids(users)
+ end
+
+ it 'does not perform extra queries when asked for users who have already been found' do
+ contributors(users)
+
+ expect { contributors([contributor.id]) }.not_to exceed_query_limit(0)
+
+ expect(contributors([contributor.id])).to eq(expected)
+ end
+
+ it 'only requests the extra users when uncached users are passed' do
+ new_contributor = create(:user)
+ new_fork_project = fork_project(project, new_contributor, repository: true)
+ second_new_user = create(:user)
+ all_users = users + [new_contributor.id, second_new_user.id]
+ create(:merge_request, :merged, author: new_contributor, target_project: project, source_project: new_fork_project, target_branch: project.default_branch.to_s)
+
+ expected_all = expected.merge(new_contributor.id => true,
+ second_new_user.id => false)
+
+ contributors(users)
+
+ queries = ActiveRecord::QueryRecorder.new { contributors(all_users) }
+
+ expect(queries.count).to eq(1)
+ expect(contributors([new_contributor.id])).to eq(expected_all)
+ end
+
+ it 'returns correct contributors' do
+ expect(contributors(users)).to eq(expected)
+ end
+ end
+
shared_examples 'max member access for users' do
let(:project) { create(:project) }
let(:group) { create(:group) }
@@ -438,9 +529,9 @@ RSpec.describe ProjectTeam do
it 'does not perform extra queries when asked for users who have already been found' do
access_levels(users)
- expect { access_levels(users) }.not_to exceed_query_limit(0)
+ expect { access_levels([maintainer.id]) }.not_to exceed_query_limit(0)
- expect(access_levels(users)).to eq(expected)
+ expect(access_levels([maintainer.id])).to eq(expected)
end
it 'only requests the extra users when uncached users are passed' do
diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb
index d9c5fed542e..29c3d0e1a73 100644
--- a/spec/models/project_wiki_spec.rb
+++ b/spec/models/project_wiki_spec.rb
@@ -17,19 +17,28 @@ RSpec.describe ProjectWiki do
end
end
- describe '#update_container_activity' do
+ describe '#after_wiki_activity' do
it 'updates project activity' do
wiki_container.update!(
last_activity_at: nil,
last_repository_updated_at: nil
)
- subject.create_page('Test Page', 'This is content')
+ subject.send(:after_wiki_activity)
wiki_container.reload
expect(wiki_container.last_activity_at).to be_within(1.minute).of(Time.current)
expect(wiki_container.last_repository_updated_at).to be_within(1.minute).of(Time.current)
end
end
+
+ describe '#after_post_receive' do
+ it 'updates project activity and expires caches' do
+ expect(wiki).to receive(:after_wiki_activity)
+ expect(ProjectCacheWorker).to receive(:perform_async).with(wiki_container.id, [], [:wiki_size])
+
+ subject.send(:after_post_receive)
+ end
+ end
end
end
diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb
index ebc9760ab14..4c3151f431c 100644
--- a/spec/models/remote_mirror_spec.rb
+++ b/spec/models/remote_mirror_spec.rb
@@ -142,6 +142,20 @@ RSpec.describe RemoteMirror, :mailer do
end
end
+ describe '#bare_url' do
+ it 'returns the URL without any credentials' do
+ remote_mirror = build(:remote_mirror, url: 'http://user:pass@example.com/foo')
+
+ expect(remote_mirror.bare_url).to eq('http://example.com/foo')
+ end
+
+ it 'returns an empty string when the URL is nil' do
+ remote_mirror = build(:remote_mirror, url: nil)
+
+ expect(remote_mirror.bare_url).to eq('')
+ end
+ end
+
describe '#update_repository' do
it 'performs update including options' do
git_remote_mirror = stub_const('Gitlab::Git::RemoteMirror', spy)
@@ -283,7 +297,7 @@ RSpec.describe RemoteMirror, :mailer do
let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'with remote mirroring disabled' do
@@ -397,7 +411,7 @@ RSpec.describe RemoteMirror, :mailer do
let(:timestamp) { Time.current - 5.minutes }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
before do
@@ -442,16 +456,18 @@ RSpec.describe RemoteMirror, :mailer do
end
describe '#disabled?' do
+ let_it_be(:project) { create(:project, :repository) }
+
subject { remote_mirror.disabled? }
context 'when disabled' do
- let(:remote_mirror) { build(:remote_mirror, enabled: false) }
+ let(:remote_mirror) { build(:remote_mirror, project: project, enabled: false) }
it { is_expected.to be_truthy }
end
context 'when enabled' do
- let(:remote_mirror) { build(:remote_mirror, enabled: true) }
+ let(:remote_mirror) { build(:remote_mirror, project: project, enabled: true) }
it { is_expected.to be_falsy }
end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index a6b79e55f02..a3042d619eb 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -1263,6 +1263,7 @@ RSpec.describe Repository do
%w(a b c/z) | %w(c d) | true
%w(a/b/z) | %w(a/b) | false # we only consider refs ambiguous before the first slash
%w(a/b/z) | %w(a/b a) | true
+ %w(ab) | %w(abc/d a b) | false
end
with_them do
diff --git a/spec/models/resource_iteration_event_spec.rb b/spec/models/resource_iteration_event_spec.rb
deleted file mode 100644
index fe1310d7bf1..00000000000
--- a/spec/models/resource_iteration_event_spec.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ResourceIterationEvent, type: :model do
- it_behaves_like 'a resource event'
- it_behaves_like 'a resource event for issues'
- it_behaves_like 'a resource event for merge requests'
-
- it_behaves_like 'having unique enum values'
- it_behaves_like 'timebox resource event validations'
- it_behaves_like 'timebox resource event actions'
-
- describe 'associations' do
- it { is_expected.to belong_to(:iteration) }
- end
-end
diff --git a/spec/models/resource_label_event_spec.rb b/spec/models/resource_label_event_spec.rb
index 6a235d3aa17..960db31d488 100644
--- a/spec/models/resource_label_event_spec.rb
+++ b/spec/models/resource_label_event_spec.rb
@@ -3,10 +3,12 @@
require 'spec_helper'
RSpec.describe ResourceLabelEvent, type: :model do
- subject { build(:resource_label_event, issue: issue) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+ let_it_be(:label) { create(:label, project: project) }
- let(:issue) { create(:issue) }
- let(:merge_request) { create(:merge_request) }
+ subject { build(:resource_label_event, issue: issue, label: label) }
it_behaves_like 'having unique enum values'
diff --git a/spec/models/resource_state_event_spec.rb b/spec/models/resource_state_event_spec.rb
index 1381b45cf9e..fc6575b2db8 100644
--- a/spec/models/resource_state_event_spec.rb
+++ b/spec/models/resource_state_event_spec.rb
@@ -11,4 +11,32 @@ RSpec.describe ResourceStateEvent, type: :model do
it_behaves_like 'a resource event'
it_behaves_like 'a resource event for issues'
it_behaves_like 'a resource event for merge requests'
+
+ describe 'validations' do
+ describe 'Issuable validation' do
+ it 'is valid if an issue is set' do
+ subject.attributes = { issue: build_stubbed(:issue), merge_request: nil }
+
+ expect(subject).to be_valid
+ end
+
+ it 'is valid if a merge request is set' do
+ subject.attributes = { issue: nil, merge_request: build_stubbed(:merge_request) }
+
+ expect(subject).to be_valid
+ end
+
+ it 'is invalid if both issue and merge request are set' do
+ subject.attributes = { issue: build_stubbed(:issue), merge_request: build_stubbed(:merge_request) }
+
+ expect(subject).not_to be_valid
+ end
+
+ it 'is invalid if there is no issuable set' do
+ subject.attributes = { issue: nil, merge_request: nil }
+
+ expect(subject).not_to be_valid
+ end
+ end
+ end
end
diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb
index c4a9c0329c7..32e2012e284 100644
--- a/spec/models/service_spec.rb
+++ b/spec/models/service_spec.rb
@@ -3,8 +3,12 @@
require 'spec_helper'
RSpec.describe Service do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+
describe "Associations" do
it { is_expected.to belong_to :project }
+ it { is_expected.to belong_to :group }
it { is_expected.to have_one :service_hook }
it { is_expected.to have_one :jira_tracker_data }
it { is_expected.to have_one :issue_tracker_data }
@@ -13,9 +17,6 @@ RSpec.describe Service do
describe 'validations' do
using RSpec::Parameterized::TableSyntax
- let(:group) { create(:group) }
- let(:project) { create(:project) }
-
it { is_expected.to validate_presence_of(:type) }
where(:project_id, :group_id, :template, :instance, :valid) do
@@ -91,17 +92,12 @@ RSpec.describe Service do
end
end
- describe '#operating?' do
- it 'is false when the service is not active' do
- expect(build(:service).operating?).to eq(false)
- end
-
- it 'is false when the service is not persisted' do
- expect(build(:service, active: true).operating?).to eq(false)
- end
+ describe '.for_group' do
+ let!(:service1) { create(:jira_service, project_id: nil, group_id: group.id) }
+ let!(:service2) { create(:jira_service) }
- it 'is true when the service is active and persisted' do
- expect(create(:service, active: true).operating?).to eq(true)
+ it 'returns the right group service' do
+ expect(described_class.for_group(group)).to match_array([service1])
end
end
@@ -134,20 +130,34 @@ RSpec.describe Service do
end
end
+ describe '#operating?' do
+ it 'is false when the service is not active' do
+ expect(build(:service).operating?).to eq(false)
+ end
+
+ it 'is false when the service is not persisted' do
+ expect(build(:service, active: true).operating?).to eq(false)
+ end
+
+ it 'is true when the service is active and persisted' do
+ expect(create(:service, active: true).operating?).to eq(true)
+ end
+ end
+
describe "Test Button" do
+ let(:service) { build(:service, project: project) }
+
describe '#can_test?' do
subject { service.can_test? }
- let(:service) { create(:service, project: project) }
-
context 'when repository is not empty' do
- let(:project) { create(:project, :repository) }
+ let(:project) { build(:project, :repository) }
it { is_expected.to be true }
end
context 'when repository is empty' do
- let(:project) { create(:project) }
+ let(:project) { build(:project) }
it { is_expected.to be true }
end
@@ -161,14 +171,23 @@ RSpec.describe Service do
it { is_expected.to be_falsey }
end
end
+
+ context 'when group-level service' do
+ Service.available_services_types.each do |service_type|
+ let(:service) do
+ service_type.constantize.new(group_id: group.id)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
end
describe '#test' do
let(:data) { 'test' }
- let(:service) { create(:service, project: project) }
context 'when repository is not empty' do
- let(:project) { create(:project, :repository) }
+ let(:project) { build(:project, :repository) }
it 'test runs execute' do
expect(service).to receive(:execute).with(data)
@@ -178,7 +197,7 @@ RSpec.describe Service do
end
context 'when repository is empty' do
- let(:project) { create(:project) }
+ let(:project) { build(:project) }
it 'test runs execute' do
expect(service).to receive(:execute).with(data)
@@ -189,14 +208,27 @@ RSpec.describe Service do
end
end
- describe '.find_or_initialize_instances' do
+ describe '.find_or_initialize_integration' do
+ let!(:service1) { create(:jira_service, project_id: nil, group_id: group.id) }
+ let!(:service2) { create(:jira_service) }
+
+ it 'returns the right service' do
+ expect(Service.find_or_initialize_integration('jira', group_id: group)).to eq(service1)
+ end
+
+ it 'does not create a new service' do
+ expect { Service.find_or_initialize_integration('redmine', group_id: group) }.not_to change { Service.count }
+ end
+ end
+
+ describe '.find_or_initialize_all' do
shared_examples 'service instances' do
it 'returns the available service instances' do
- expect(Service.find_or_initialize_instances.pluck(:type)).to match_array(Service.available_services_types)
+ expect(Service.find_or_initialize_all(Service.for_instance).pluck(:type)).to match_array(Service.available_services_types)
end
it 'does not create service instances' do
- expect { Service.find_or_initialize_instances }.not_to change { Service.count }
+ expect { Service.find_or_initialize_all(Service.for_instance) }.not_to change { Service.count }
end
end
@@ -211,9 +243,9 @@ RSpec.describe Service do
it_behaves_like 'service instances'
- context 'with a previous existing service (Previous) and a new service (Asana)' do
+ context 'with a previous existing service (MockCiService) and a new service (Asana)' do
before do
- Service.insert(type: 'PreviousService', instance: true)
+ Service.insert(type: 'MockCiService', instance: true)
Service.delete_by(type: 'AsanaService', instance: true)
end
@@ -231,8 +263,6 @@ RSpec.describe Service do
end
describe 'template' do
- let(:project) { create(:project) }
-
shared_examples 'retrieves service templates' do
it 'returns the available service templates' do
expect(Service.find_or_create_templates.pluck(:type)).to match_array(Service.available_services_types)
@@ -390,29 +420,49 @@ RSpec.describe Service do
end
end
- describe 'instance' do
- describe '.instance_for' do
- let_it_be(:jira_service) { create(:jira_service, :instance) }
- let_it_be(:slack_service) { create(:slack_service, :instance) }
-
- subject { described_class.instance_for(type) }
+ describe '.default_integration' do
+ context 'with an instance-level service' do
+ let_it_be(:instance_service) { create(:jira_service, :instance) }
- context 'Hipchat serivce' do
- let(:type) { 'HipchatService' }
+ it 'returns the instance service' do
+ expect(described_class.default_integration('JiraService', project)).to eq(instance_service)
+ end
- it { is_expected.to eq(nil) }
+ it 'returns nil for nonexistent service type' do
+ expect(described_class.default_integration('HipchatService', project)).to eq(nil)
end
- context 'Jira serivce' do
- let(:type) { 'JiraService' }
+ context 'with a group service' do
+ let_it_be(:group_service) { create(:jira_service, group_id: group.id, project_id: nil) }
- it { is_expected.to eq(jira_service) }
- end
+ it 'returns the group service for a project' do
+ expect(described_class.default_integration('JiraService', project)).to eq(group_service)
+ end
+
+ it 'returns the instance service for a group' do
+ expect(described_class.default_integration('JiraService', group)).to eq(instance_service)
+ end
- context 'Slack serivce' do
- let(:type) { 'SlackService' }
+ context 'with a subgroup' do
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let!(:project) { create(:project, group: subgroup) }
- it { is_expected.to eq(slack_service) }
+ it 'returns the closest group service for a project' do
+ expect(described_class.default_integration('JiraService', project)).to eq(group_service)
+ end
+
+ it 'returns the closest group service for a subgroup' do
+ expect(described_class.default_integration('JiraService', subgroup)).to eq(group_service)
+ end
+
+ context 'having a service' do
+ let!(:subgroup_service) { create(:jira_service, group_id: subgroup.id, project_id: nil) }
+
+ it 'returns the closest group service for a project' do
+ expect(described_class.default_integration('JiraService', project)).to eq(subgroup_service)
+ end
+ end
+ end
end
end
end
@@ -420,7 +470,7 @@ RSpec.describe Service do
describe "{property}_changed?" do
let(:service) do
BambooService.create(
- project: create(:project),
+ project: project,
properties: {
bamboo_url: 'http://gitlab.com',
username: 'mic',
@@ -460,7 +510,7 @@ RSpec.describe Service do
describe "{property}_touched?" do
let(:service) do
BambooService.create(
- project: create(:project),
+ project: project,
properties: {
bamboo_url: 'http://gitlab.com',
username: 'mic',
@@ -500,7 +550,7 @@ RSpec.describe Service do
describe "{property}_was" do
let(:service) do
BambooService.create(
- project: create(:project),
+ project: project,
properties: {
bamboo_url: 'http://gitlab.com',
username: 'mic',
@@ -540,7 +590,7 @@ RSpec.describe Service do
describe 'initialize service with no properties' do
let(:service) do
BugzillaService.create(
- project: create(:project),
+ project: project,
project_url: 'http://gitlab.example.com'
)
end
@@ -555,7 +605,6 @@ RSpec.describe Service do
end
describe "callbacks" do
- let(:project) { create(:project) }
let!(:service) do
RedmineService.new(
project: project,
@@ -622,8 +671,7 @@ RSpec.describe Service do
end
context 'logging' do
- let(:project) { create(:project) }
- let(:service) { create(:service, project: project) }
+ let(:service) { build(:service, project: project) }
let(:test_message) { "test message" }
let(:arguments) do
{
diff --git a/spec/models/snippet_input_action_spec.rb b/spec/models/snippet_input_action_spec.rb
index ca61b80df4c..43dc70bea98 100644
--- a/spec/models/snippet_input_action_spec.rb
+++ b/spec/models/snippet_input_action_spec.rb
@@ -22,9 +22,9 @@ RSpec.describe SnippetInputAction do
:move | 'foobar' | 'foobar' | nil | nil | false | :previous_path
:move | 'foobar' | 'foobar' | '' | nil | false | :previous_path
:move | 'foobar' | 'foobar' | 'foobar' | nil | false | :file_path
- :move | nil | 'foobar' | 'foobar' | nil | false | :file_path
- :move | '' | 'foobar' | 'foobar' | nil | false | :file_path
- :move | nil | 'foobar' | 'foo1' | nil | false | :file_path
+ :move | nil | 'foobar' | 'foobar' | nil | true | nil
+ :move | '' | 'foobar' | 'foobar' | nil | true | nil
+ :move | nil | 'foobar' | 'foo1' | nil | true | nil
:move | 'foobar' | nil | 'foo1' | nil | true | nil
:move | 'foobar' | '' | 'foo1' | nil | true | nil
:create | 'foobar' | nil | 'foobar' | nil | false | :content
diff --git a/spec/models/snippet_repository_spec.rb b/spec/models/snippet_repository_spec.rb
index 0f5e0bfc75c..95602a4de0e 100644
--- a/spec/models/snippet_repository_spec.rb
+++ b/spec/models/snippet_repository_spec.rb
@@ -108,6 +108,7 @@ RSpec.describe SnippetRepository do
before do
allow(snippet).to receive(:repository).and_return(repo)
allow(repo).to receive(:ls_files).and_return([])
+ allow(repo).to receive(:root_ref).and_return('master')
end
it 'infers the commit action based on the parameters if not present' do
@@ -197,7 +198,7 @@ RSpec.describe SnippetRepository do
shared_examples 'snippet repository with file names' do |*filenames|
it 'sets a name for unnamed files' do
- ls_files = snippet.repository.ls_files(nil)
+ ls_files = snippet.repository.ls_files(snippet.default_branch)
expect(ls_files).to include(*filenames)
end
end
@@ -306,6 +307,6 @@ RSpec.describe SnippetRepository do
end
def first_blob(snippet)
- snippet.repository.blob_at('master', snippet.repository.ls_files(nil).first)
+ snippet.repository.blob_at('master', snippet.repository.ls_files(snippet.default_branch).first)
end
end
diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb
index 3f9c6981de1..ab614a6d45c 100644
--- a/spec/models/snippet_spec.rb
+++ b/spec/models/snippet_spec.rb
@@ -133,10 +133,10 @@ RSpec.describe Snippet do
end
describe '#file_name' do
- let(:project) { create(:project) }
+ let(:snippet) { build(:snippet, file_name: file_name) }
context 'file_name is nil' do
- let(:snippet) { create(:snippet, project: project, file_name: nil) }
+ let(:file_name) { nil }
it 'returns an empty string' do
expect(snippet.file_name).to eq ''
@@ -144,10 +144,10 @@ RSpec.describe Snippet do
end
context 'file_name is not nil' do
- let(:snippet) { create(:snippet, project: project, file_name: 'foo.txt') }
+ let(:file_name) { 'foo.txt' }
it 'returns the file_name' do
- expect(snippet.file_name).to eq 'foo.txt'
+ expect(snippet.file_name).to eq file_name
end
end
end
@@ -161,7 +161,7 @@ RSpec.describe Snippet do
end
describe '.search' do
- let(:snippet) { create(:snippet, title: 'test snippet', description: 'description') }
+ let_it_be(:snippet) { create(:snippet, title: 'test snippet', description: 'description') }
it 'returns snippets with a matching title' do
expect(described_class.search(snippet.title)).to eq([snippet])
@@ -219,25 +219,23 @@ RSpec.describe Snippet do
end
describe '.with_optional_visibility' do
+ let_it_be(:public_snippet) { create(:snippet, :public) }
+ let_it_be(:private_snippet) { create(:snippet, :private) }
+
context 'when a visibility level is provided' do
it 'returns snippets with the given visibility' do
- create(:snippet, :private)
-
- snippet = create(:snippet, :public)
snippets = described_class
.with_optional_visibility(Gitlab::VisibilityLevel::PUBLIC)
- expect(snippets).to eq([snippet])
+ expect(snippets).to eq([public_snippet])
end
end
context 'when a visibility level is not provided' do
it 'returns all snippets' do
- snippet1 = create(:snippet, :public)
- snippet2 = create(:snippet, :private)
snippets = described_class.with_optional_visibility
- expect(snippets).to include(snippet1, snippet2)
+ expect(snippets).to include(public_snippet, private_snippet)
end
end
end
@@ -254,12 +252,13 @@ RSpec.describe Snippet do
end
describe '.only_include_projects_visible_to' do
- let!(:project1) { create(:project, :public) }
- let!(:project2) { create(:project, :internal) }
- let!(:project3) { create(:project, :private) }
- let!(:snippet1) { create(:project_snippet, project: project1) }
- let!(:snippet2) { create(:project_snippet, project: project2) }
- let!(:snippet3) { create(:project_snippet, project: project3) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:project1) { create(:project_empty_repo, :public, namespace: author.namespace) }
+ let_it_be(:project2) { create(:project_empty_repo, :internal, namespace: author.namespace) }
+ let_it_be(:project3) { create(:project_empty_repo, :private, namespace: author.namespace) }
+ let_it_be(:snippet1) { create(:project_snippet, project: project1, author: author) }
+ let_it_be(:snippet2) { create(:project_snippet, project: project2, author: author) }
+ let_it_be(:snippet3) { create(:project_snippet, project: project3, author: author) }
context 'when a user is provided' do
it 'returns snippets visible to the user' do
@@ -283,55 +282,47 @@ RSpec.describe Snippet do
end
describe 'only_include_projects_with_snippets_enabled' do
- context 'when the include_private option is enabled' do
- it 'includes snippets for projects with snippets set to private' do
- project = create(:project)
-
- project.project_feature
- .update(snippets_access_level: ProjectFeature::PRIVATE)
+ let_it_be(:project, reload: true) { create(:project_empty_repo) }
+ let_it_be(:snippet) { create(:project_snippet, project: project) }
- snippet = create(:project_snippet, project: project)
+ let(:access_level) { ProjectFeature::ENABLED }
- snippets = described_class
- .only_include_projects_with_snippets_enabled(include_private: true)
-
- expect(snippets).to eq([snippet])
- end
+ before do
+ project.project_feature.update(snippets_access_level: access_level)
end
- context 'when the include_private option is not enabled' do
- it 'does not include snippets for projects that have snippets set to private' do
- project = create(:project)
+ it 'includes snippets for projects with snippets enabled' do
+ snippets = described_class.only_include_projects_with_snippets_enabled
- project.project_feature
- .update(snippets_access_level: ProjectFeature::PRIVATE)
+ expect(snippets).to eq([snippet])
+ end
- create(:project_snippet, project: project)
+ context 'when snippet_access_level is private' do
+ let(:access_level) { ProjectFeature::PRIVATE }
- snippets = described_class.only_include_projects_with_snippets_enabled
+ context 'when the include_private option is enabled' do
+ it 'includes snippets for projects with snippets set to private' do
+ snippets = described_class.only_include_projects_with_snippets_enabled(include_private: true)
- expect(snippets).to be_empty
+ expect(snippets).to eq([snippet])
+ end
end
- end
-
- it 'includes snippets for projects with snippets enabled' do
- project = create(:project)
- project.project_feature
- .update(snippets_access_level: ProjectFeature::ENABLED)
+ context 'when the include_private option is not enabled' do
+ it 'does not include snippets for projects that have snippets set to private' do
+ snippets = described_class.only_include_projects_with_snippets_enabled
- snippet = create(:project_snippet, project: project)
- snippets = described_class.only_include_projects_with_snippets_enabled
-
- expect(snippets).to eq([snippet])
+ expect(snippets).to be_empty
+ end
+ end
end
end
describe '.only_include_authorized_projects' do
it 'only includes snippets for projects the user is authorized to see' do
user = create(:user)
- project1 = create(:project, :private)
- project2 = create(:project, :private)
+ project1 = create(:project_empty_repo, :private)
+ project2 = create(:project_empty_repo, :private)
project1.team.add_developer(user)
@@ -345,43 +336,34 @@ RSpec.describe Snippet do
end
describe '.for_project_with_user' do
- context 'when a user is provided' do
- it 'returns an empty collection if the user can not view the snippets' do
- project = create(:project, :private)
- user = create(:user)
+ let_it_be(:public_project) { create(:project_empty_repo, :public) }
+ let_it_be(:private_project) { create(:project_empty_repo, :private) }
- project.project_feature
- .update(snippets_access_level: ProjectFeature::ENABLED)
+ context 'when a user is provided' do
+ let_it_be(:user) { create(:user) }
- create(:project_snippet, :public, project: project)
+ it 'returns an empty collection if the user can not view the snippets' do
+ create(:project_snippet, :public, project: private_project)
- expect(described_class.for_project_with_user(project, user)).to be_empty
+ expect(described_class.for_project_with_user(private_project, user)).to be_empty
end
it 'returns the snippets if the user is a member of the project' do
- project = create(:project, :private)
- user = create(:user)
- snippet = create(:project_snippet, project: project)
+ snippet = create(:project_snippet, project: private_project)
- project.team.add_developer(user)
+ private_project.team.add_developer(user)
- snippets = described_class.for_project_with_user(project, user)
+ snippets = described_class.for_project_with_user(private_project, user)
expect(snippets).to eq([snippet])
end
it 'returns public snippets for a public project the user is not a member of' do
- project = create(:project, :public)
-
- project.project_feature
- .update(snippets_access_level: ProjectFeature::ENABLED)
+ snippet = create(:project_snippet, :public, project: public_project)
- user = create(:user)
- snippet = create(:project_snippet, :public, project: project)
+ create(:project_snippet, :private, project: public_project)
- create(:project_snippet, :private, project: project)
-
- snippets = described_class.for_project_with_user(project, user)
+ snippets = described_class.for_project_with_user(public_project, user)
expect(snippets).to eq([snippet])
end
@@ -389,26 +371,17 @@ RSpec.describe Snippet do
context 'when a user is not provided' do
it 'returns an empty collection for a private project' do
- project = create(:project, :private)
-
- project.project_feature
- .update(snippets_access_level: ProjectFeature::ENABLED)
+ create(:project_snippet, :public, project: private_project)
- create(:project_snippet, :public, project: project)
-
- expect(described_class.for_project_with_user(project)).to be_empty
+ expect(described_class.for_project_with_user(private_project)).to be_empty
end
it 'returns public snippets for a public project' do
- project = create(:project, :public)
- snippet = create(:project_snippet, :public, project: project)
-
- project.project_feature
- .update(snippets_access_level: ProjectFeature::PUBLIC)
+ snippet = create(:project_snippet, :public, project: public_project)
- create(:project_snippet, :private, project: project)
+ create(:project_snippet, :private, project: public_project)
- snippets = described_class.for_project_with_user(project)
+ snippets = described_class.for_project_with_user(public_project)
expect(snippets).to eq([snippet])
end
@@ -430,34 +403,30 @@ RSpec.describe Snippet do
end
describe '#participants' do
- let(:project) { create(:project, :public) }
- let(:snippet) { create(:snippet, content: 'foo', project: project) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:snippet) { create(:snippet, content: 'foo', project: project) }
- let!(:note1) do
+ let_it_be(:note1) do
create(:note_on_project_snippet,
noteable: snippet,
project: project,
note: 'a')
end
- let!(:note2) do
+ let_it_be(:note2) do
create(:note_on_project_snippet,
noteable: snippet,
project: project,
note: 'b')
end
- it 'includes the snippet author' do
- expect(snippet.participants).to include(snippet.author)
- end
-
- it 'includes the note authors' do
- expect(snippet.participants).to include(note1.author, note2.author)
+ it 'includes the snippet author and note authors' do
+ expect(snippet.participants).to include(snippet.author, note1.author, note2.author)
end
end
describe '#check_for_spam' do
- let(:snippet) { create :snippet, visibility_level: visibility_level }
+ let(:snippet) { create(:snippet, visibility_level: visibility_level) }
subject do
snippet.assign_attributes(title: title)
@@ -500,7 +469,7 @@ RSpec.describe Snippet do
end
describe '#blob' do
- let(:snippet) { create(:snippet) }
+ let(:snippet) { build(:snippet) }
it 'returns a blob representing the snippet data' do
blob = snippet.blob
@@ -514,6 +483,14 @@ RSpec.describe Snippet do
describe '#blobs' do
let(:snippet) { create(:snippet) }
+ it 'returns a blob representing the snippet data' do
+ blob = snippet.blob
+
+ expect(blob).to be_a(Blob)
+ expect(blob.path).to eq(snippet.file_name)
+ expect(blob.data).to eq(snippet.content)
+ end
+
context 'when repository does not exist' do
it 'returns empty array' do
expect(snippet.blobs).to be_empty
@@ -527,14 +504,6 @@ RSpec.describe Snippet do
expect(snippet.blobs).to all(be_a(Blob))
end
end
-
- it 'returns a blob representing the snippet data' do
- blob = snippet.blob
-
- expect(blob).to be_a(Blob)
- expect(blob.path).to eq(snippet.file_name)
- expect(blob.data).to eq(snippet.content)
- end
end
describe '#to_json' do
@@ -554,7 +523,7 @@ RSpec.describe Snippet do
end
describe '#storage' do
- let(:snippet) { create(:snippet) }
+ let(:snippet) { build(:snippet, id: 1) }
it "stores snippet in #{Storage::Hashed::SNIPPET_REPOSITORY_PATH_PREFIX} dir" do
expect(snippet.storage.disk_path).to start_with Storage::Hashed::SNIPPET_REPOSITORY_PATH_PREFIX
@@ -775,6 +744,16 @@ RSpec.describe Snippet do
subject
end
+
+ context 'when ref is nil' do
+ let(:ref) { nil }
+
+ it 'lists files from the repository from the deafult_branch' do
+ expect(snippet.repository).to receive(:ls_files).with(snippet.default_branch)
+
+ subject
+ end
+ end
end
context 'when snippet does not have a repository' do
@@ -787,4 +766,26 @@ RSpec.describe Snippet do
end
end
end
+
+ describe '#multiple_files?' do
+ subject { snippet.multiple_files? }
+
+ context 'when snippet has multiple files' do
+ let(:snippet) { create(:snippet, :repository) }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when snippet does not have multiple files' do
+ let(:snippet) { create(:snippet, :empty_repo) }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when the snippet does not have a repository' do
+ let(:snippet) { build(:snippet) }
+
+ it { is_expected.to be_falsey }
+ end
+ end
end
diff --git a/spec/models/snippet_statistics_spec.rb b/spec/models/snippet_statistics_spec.rb
index ad25bd7b3be..8def6a0bbd4 100644
--- a/spec/models/snippet_statistics_spec.rb
+++ b/spec/models/snippet_statistics_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe SnippetStatistics do
subject { statistics.update_file_count }
it 'updates the count of files' do
- file_count = snippet_with_repo.repository.ls_files(nil).count
+ file_count = snippet_with_repo.repository.ls_files(snippet_with_repo.default_branch).count
subject
diff --git a/spec/models/terraform/state_spec.rb b/spec/models/terraform/state_spec.rb
index 68bb86bfa49..01ae80a61d1 100644
--- a/spec/models/terraform/state_spec.rb
+++ b/spec/models/terraform/state_spec.rb
@@ -7,8 +7,9 @@ RSpec.describe Terraform::State do
let(:terraform_state_file) { fixture_file('terraform/terraform.tfstate') }
- it { is_expected.to belong_to(:project) }
+ it { is_expected.to be_a FileStoreMounter }
+ it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:locked_by_user).class_name('User') }
it { is_expected.to validate_presence_of(:project_id) }
@@ -23,14 +24,6 @@ RSpec.describe Terraform::State do
expect(subject.file.read).to eq(terraform_state_file)
end
end
-
- context 'when no file exists' do
- subject { create(:terraform_state) }
-
- it 'creates a default file' do
- expect(subject.file.read).to eq('{"version":1}')
- end
- end
end
describe '#file_store' do
@@ -56,4 +49,55 @@ RSpec.describe Terraform::State do
it_behaves_like 'mounted file in local store'
end
end
+
+ describe '#latest_file' do
+ subject { terraform_state.latest_file }
+
+ context 'versioning is enabled' do
+ let(:terraform_state) { create(:terraform_state, :with_version) }
+ let(:latest_version) { terraform_state.latest_version }
+
+ it { is_expected.to eq latest_version.file }
+
+ context 'but no version exists yet' do
+ let(:terraform_state) { create(:terraform_state) }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context 'versioning is disabled' do
+ let(:terraform_state) { create(:terraform_state, :with_file) }
+
+ it { is_expected.to eq terraform_state.file }
+ end
+ end
+
+ describe '#update_file!' do
+ let(:version) { 2 }
+ let(:data) { Hash[terraform_version: '0.12.21'].to_json }
+
+ subject { terraform_state.update_file!(CarrierWaveStringFile.new(data), version: version) }
+
+ context 'versioning is enabled' do
+ let(:terraform_state) { create(:terraform_state) }
+
+ it 'creates a new version' do
+ expect { subject }.to change { Terraform::StateVersion.count }
+
+ expect(terraform_state.latest_version.version).to eq(version)
+ expect(terraform_state.latest_version.file.read).to eq(data)
+ end
+ end
+
+ context 'versioning is disabled' do
+ let(:terraform_state) { create(:terraform_state, :with_file) }
+
+ it 'modifies the existing state record' do
+ expect { subject }.not_to change { Terraform::StateVersion.count }
+
+ expect(terraform_state.latest_file.read).to eq(data)
+ end
+ end
+ end
end
diff --git a/spec/models/terraform/state_version_spec.rb b/spec/models/terraform/state_version_spec.rb
new file mode 100644
index 00000000000..72dd29e1571
--- /dev/null
+++ b/spec/models/terraform/state_version_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Terraform::StateVersion do
+ it { is_expected.to be_a FileStoreMounter }
+
+ it { is_expected.to belong_to(:terraform_state).required }
+ it { is_expected.to belong_to(:created_by_user).class_name('User').optional }
+
+ describe 'scopes' do
+ describe '.ordered_by_version_desc' do
+ let(:terraform_state) { create(:terraform_state) }
+ let(:versions) { [4, 2, 5, 1, 3] }
+
+ subject { described_class.ordered_by_version_desc }
+
+ before do
+ versions.each do |version|
+ create(:terraform_state_version, terraform_state: terraform_state, version: version)
+ end
+ end
+
+ it { expect(subject.map(&:version)).to eq(versions.sort.reverse) }
+ end
+ end
+
+ context 'file storage' do
+ subject { create(:terraform_state_version) }
+
+ before do
+ stub_terraform_state_object_storage(Terraform::StateUploader)
+ end
+
+ describe '#file' do
+ let(:terraform_state_file) { fixture_file('terraform/terraform.tfstate') }
+
+ before do
+ subject.file = CarrierWaveStringFile.new(terraform_state_file)
+ subject.save!
+ end
+
+ it 'returns the saved file' do
+ expect(subject.file.read).to eq(terraform_state_file)
+ end
+ end
+
+ describe '#file_store' do
+ it 'returns the value' do
+ [ObjectStorage::Store::LOCAL, ObjectStorage::Store::REMOTE].each do |store|
+ subject.update!(file_store: store)
+
+ expect(subject.file_store).to eq(store)
+ end
+ end
+ end
+
+ describe '#update_file_store' do
+ context 'when file is stored in object storage' do
+ it 'sets file_store to remote' do
+ expect(subject.file_store).to eq(ObjectStorage::Store::REMOTE)
+ end
+ end
+
+ context 'when file is stored locally' do
+ before do
+ stub_terraform_state_object_storage(enabled: false)
+ end
+
+ it 'sets file_store to local' do
+ expect(subject.file_store).to eq(ObjectStorage::Store::LOCAL)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/user_agent_detail_spec.rb b/spec/models/user_agent_detail_spec.rb
index e3f3d9c342b..ca81ef38ecc 100644
--- a/spec/models/user_agent_detail_spec.rb
+++ b/spec/models/user_agent_detail_spec.rb
@@ -18,8 +18,10 @@ RSpec.describe UserAgentDetail do
end
describe '.valid?' do
+ let(:issue) { create(:issue) }
+
it 'is valid with a subject' do
- detail = build(:user_agent_detail)
+ detail = build(:user_agent_detail, subject: issue)
expect(detail).to be_valid
end
diff --git a/spec/models/user_interacted_project_spec.rb b/spec/models/user_interacted_project_spec.rb
index 2fec8be76e8..aa038b06d8d 100644
--- a/spec/models/user_interacted_project_spec.rb
+++ b/spec/models/user_interacted_project_spec.rb
@@ -3,14 +3,17 @@
require 'spec_helper'
RSpec.describe UserInteractedProject do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:author) { project.creator }
+
describe '.track' do
subject { described_class.track(event) }
- let(:event) { build(:event) }
+ let(:event) { build(:event, project: project, author: author) }
Event.actions.each_key do |action|
context "for all actions (event types)" do
- let(:event) { build(:event, action: action) }
+ let(:event) { build(:event, project: project, author: author, action: action) }
it 'creates a record' do
expect { subject }.to change { described_class.count }.from(0).to(1)
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index e9077ed4143..1841288cd4b 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -47,6 +47,9 @@ RSpec.describe User do
it { is_expected.to delegate_method(:sourcegraph_enabled).to(:user_preference) }
it { is_expected.to delegate_method(:sourcegraph_enabled=).to(:user_preference).with_arguments(:args) }
+ it { is_expected.to delegate_method(:gitpod_enabled).to(:user_preference) }
+ it { is_expected.to delegate_method(:gitpod_enabled=).to(:user_preference).with_arguments(:args) }
+
it { is_expected.to delegate_method(:setup_for_company).to(:user_preference) }
it { is_expected.to delegate_method(:setup_for_company=).to(:user_preference).with_arguments(:args) }
@@ -68,6 +71,7 @@ RSpec.describe User do
it { is_expected.to have_one(:namespace) }
it { is_expected.to have_one(:status) }
it { is_expected.to have_one(:user_detail) }
+ it { is_expected.to have_one(:atlassian_identity) }
it { is_expected.to have_one(:user_highest_role) }
it { is_expected.to have_many(:snippets).dependent(:destroy) }
it { is_expected.to have_many(:members) }
@@ -179,6 +183,58 @@ RSpec.describe User do
end.to have_enqueued_job.on_queue('mailers').exactly(:twice)
end
end
+
+ context 'emails sent on changing password' do
+ context 'when password is updated' do
+ context 'default behaviour' do
+ it 'enqueues the `password changed` email' do
+ user.password = User.random_password
+
+ expect { user.save! }.to have_enqueued_mail(DeviseMailer, :password_change)
+ end
+
+ it 'does not enqueue the `admin changed your password` email' do
+ user.password = User.random_password
+
+ expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+ end
+
+ context '`admin changed your password` email' do
+ it 'is enqueued only when explicitly allowed' do
+ user.password = User.random_password
+ user.send_only_admin_changed_your_password_notification!
+
+ expect { user.save! }.to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+
+ it '`password changed` email is not enqueued if it is explicitly allowed' do
+ user.password = User.random_password
+ user.send_only_admin_changed_your_password_notification!
+
+ expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_changed)
+ end
+
+ it 'is not enqueued if sending notifications on password updates is turned off as per Devise config' do
+ user.password = User.random_password
+ user.send_only_admin_changed_your_password_notification!
+
+ allow(Devise).to receive(:send_password_change_notification).and_return(false)
+
+ expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+ end
+ end
+
+ context 'when password is not updated' do
+ it 'does not enqueue the `admin changed your password` email even if explicitly allowed' do
+ user.name = 'John'
+ user.send_only_admin_changed_your_password_notification!
+
+ expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+ end
+ end
end
describe 'validations' do
@@ -659,30 +715,40 @@ RSpec.describe User do
expect(users_with_two_factor).not_to include(user_without_2fa.id)
end
- it "returns users with 2fa enabled via U2F" do
- user_with_2fa = create(:user, :two_factor_via_u2f)
- user_without_2fa = create(:user)
- users_with_two_factor = described_class.with_two_factor.pluck(:id)
+ shared_examples "returns the right users" do |trait|
+ it "returns users with 2fa enabled via hardware token" do
+ user_with_2fa = create(:user, trait)
+ user_without_2fa = create(:user)
+ users_with_two_factor = described_class.with_two_factor.pluck(:id)
- expect(users_with_two_factor).to include(user_with_2fa.id)
- expect(users_with_two_factor).not_to include(user_without_2fa.id)
- end
+ expect(users_with_two_factor).to include(user_with_2fa.id)
+ expect(users_with_two_factor).not_to include(user_without_2fa.id)
+ end
- it "returns 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_with_two_factor = described_class.with_two_factor.pluck(:id)
+ it "returns users with 2fa enabled via OTP and hardware token" do
+ user_with_2fa = create(:user, :two_factor_via_otp, trait)
+ user_without_2fa = create(:user)
+ users_with_two_factor = described_class.with_two_factor.pluck(:id)
- expect(users_with_two_factor).to eq([user_with_2fa.id])
- expect(users_with_two_factor).not_to include(user_without_2fa.id)
+ expect(users_with_two_factor).to eq([user_with_2fa.id])
+ expect(users_with_two_factor).not_to include(user_without_2fa.id)
+ end
+
+ it 'works with ORDER BY' do
+ user_with_2fa = create(:user, :two_factor_via_otp, trait)
+
+ expect(described_class
+ .with_two_factor
+ .reorder_by_name).to eq([user_with_2fa])
+ end
end
- it 'works with ORDER BY' do
- user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f)
+ describe "and U2F" do
+ it_behaves_like "returns the right users", :two_factor_via_u2f
+ end
- expect(described_class
- .with_two_factor
- .reorder_by_name).to eq([user_with_2fa])
+ describe "and WebAuthn" do
+ it_behaves_like "returns the right users", :two_factor_via_webauthn
end
end
@@ -696,22 +762,44 @@ RSpec.describe User do
expect(users_without_two_factor).not_to include(user_with_2fa.id)
end
- 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)
+ 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)
+ 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
- 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)
+ describe "and webauthn" do
+ it "excludes users with 2fa enabled via WebAuthn" do
+ user_with_2fa = create(:user, :two_factor_via_webauthn)
+ 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)
+ 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 WebAuthn" do
+ user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_webauthn)
+ 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
end
@@ -1662,7 +1750,7 @@ RSpec.describe User do
# add user to project
project.add_maintainer(user)
- # create invite to projet
+ # create invite to project
create(:project_member, :developer, project: project, invite_token: '1234', invite_email: 'inviteduser1@example.com')
# create request to join project
@@ -4490,6 +4578,44 @@ RSpec.describe User do
end
end
+ describe '#notification_settings_for_groups' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:groups) { create_list(:group, 2) }
+
+ subject { user.notification_settings_for_groups(arg) }
+
+ before do
+ groups.each do |group|
+ group.add_maintainer(user)
+ end
+ end
+
+ shared_examples_for 'notification_settings_for_groups method' do
+ it 'returns NotificationSetting objects for provided groups', :aggregate_failures do
+ expect(subject.count).to eq(groups.count)
+ expect(subject.map(&:source_id)).to match_array(groups.map(&:id))
+ end
+ end
+
+ context 'when given an ActiveRecord relationship' do
+ let_it_be(:arg) { Group.where(id: groups.map(&:id)) }
+
+ it_behaves_like 'notification_settings_for_groups method'
+
+ it 'uses #select to maintain lazy querying behavior' do
+ expect(arg).to receive(:select).and_call_original
+
+ subject
+ end
+ end
+
+ context 'when given an Array of Groups' do
+ let_it_be(:arg) { groups }
+
+ it_behaves_like 'notification_settings_for_groups method'
+ end
+ end
+
describe '#notification_email_for' do
let(:user) { create(:user) }
let(:group) { create(:group) }
diff --git a/spec/models/x509_certificate_spec.rb b/spec/models/x509_certificate_spec.rb
index 880c5014a84..d3b4470d3f4 100644
--- a/spec/models/x509_certificate_spec.rb
+++ b/spec/models/x509_certificate_spec.rb
@@ -68,6 +68,8 @@ RSpec.describe X509Certificate do
end
describe 'validators' do
+ let_it_be(:issuer) { create(:x509_issuer) }
+
it 'accepts correct subject_key_identifier' do
subject_key_identifiers = [
'AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB',
@@ -75,7 +77,7 @@ RSpec.describe X509Certificate do
]
subject_key_identifiers.each do |identifier|
- expect(build(:x509_certificate, subject_key_identifier: identifier)).to be_valid
+ expect(build(:x509_certificate, x509_issuer: issuer, subject_key_identifier: identifier)).to be_valid
end
end
@@ -88,7 +90,7 @@ RSpec.describe X509Certificate do
]
subject_key_identifiers.each do |identifier|
- expect(build(:x509_certificate, subject_key_identifier: identifier)).to be_invalid
+ expect(build(:x509_certificate, x509_issuer: issuer, subject_key_identifier: identifier)).to be_invalid
end
end
@@ -99,7 +101,7 @@ RSpec.describe X509Certificate do
]
emails.each do |email|
- expect(build(:x509_certificate, email: email)).to be_valid
+ expect(build(:x509_certificate, x509_issuer: issuer, email: email)).to be_valid
end
end
@@ -110,20 +112,20 @@ RSpec.describe X509Certificate do
]
emails.each do |email|
- expect(build(:x509_certificate, email: email)).to be_invalid
+ expect(build(:x509_certificate, x509_issuer: issuer, email: email)).to be_invalid
end
end
it 'accepts valid serial_number' do
- expect(build(:x509_certificate, serial_number: 123412341234)).to be_valid
+ expect(build(:x509_certificate, x509_issuer: issuer, serial_number: 123412341234)).to be_valid
# rfc 5280 - 4.1.2.2 Serial number (20 octets is the maximum)
- expect(build(:x509_certificate, serial_number: 1461501637330902918203684832716283019655932542975)).to be_valid
- expect(build(:x509_certificate, serial_number: 'ffffffffffffffffffffffffffffffffffffffff'.to_i(16))).to be_valid
+ expect(build(:x509_certificate, x509_issuer: issuer, serial_number: 1461501637330902918203684832716283019655932542975)).to be_valid
+ expect(build(:x509_certificate, x509_issuer: issuer, serial_number: 'ffffffffffffffffffffffffffffffffffffffff'.to_i(16))).to be_valid
end
it 'rejects invalid serial_number' do
- expect(build(:x509_certificate, serial_number: "sgsgfsdgdsfg")).to be_invalid
+ expect(build(:x509_certificate, x509_issuer: issuer, serial_number: "sgsgfsdgdsfg")).to be_invalid
end
end
end
diff --git a/spec/policies/design_management/design_policy_spec.rb b/spec/policies/design_management/design_policy_spec.rb
index 5cf2f376edf..5a74d979ef3 100644
--- a/spec/policies/design_management/design_policy_spec.rb
+++ b/spec/policies/design_management/design_policy_spec.rb
@@ -131,17 +131,6 @@ RSpec.describe DesignManagement::DesignPolicy do
it_behaves_like "design abilities available for members"
- context 'when reorder_designs is not enabled' do
- before do
- stub_feature_flags(reorder_designs: false)
- end
-
- let(:current_user) { developer }
-
- it { is_expected.to be_allowed(*(developer_design_abilities - [:move_design])) }
- it { is_expected.to be_disallowed(:move_design) }
- end
-
context "for guests in private projects" do
let_it_be(:project) { create(:project, :private) }
let(:current_user) { guest }
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
index 4954eafe338..6cd1c201c62 100644
--- a/spec/policies/global_policy_spec.rb
+++ b/spec/policies/global_policy_spec.rb
@@ -370,46 +370,6 @@ RSpec.describe GlobalPolicy do
end
end
- describe 'read instance statistics' do
- context 'regular user' do
- it { is_expected.to be_allowed(:read_instance_statistics) }
-
- context 'when instance statistics are set to private' do
- before do
- stub_application_setting(instance_statistics_visibility_private: true)
- end
-
- it { is_expected.not_to be_allowed(:read_instance_statistics) }
- end
- end
-
- context 'admin' do
- let(:current_user) { create(:admin) }
-
- it { is_expected.to be_allowed(:read_instance_statistics) }
-
- context 'when instance statistics are set to private' do
- before do
- stub_application_setting(instance_statistics_visibility_private: true)
- end
-
- context 'when admin mode is enabled', :enable_admin_mode do
- it { is_expected.to be_allowed(:read_instance_statistics) }
- end
-
- context 'when admin mode is disabled' do
- it { is_expected.to be_disallowed(:read_instance_statistics) }
- end
- end
- end
-
- context 'anonymous' do
- let(:current_user) { nil }
-
- it { is_expected.not_to be_allowed(:read_instance_statistics) }
- end
- end
-
describe 'slash commands' do
context 'regular user' do
it { is_expected.to be_allowed(:use_slash_commands) }
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index 3e0ea164e3d..dbe444acb58 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -768,4 +768,48 @@ RSpec.describe GroupPolicy do
end
end
end
+
+ describe 'create_jira_connect_subscription' do
+ context 'admin' do
+ let(:current_user) { admin }
+
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with non member' do
+ let(:current_user) { create(:user) }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+ end
end
diff --git a/spec/policies/issuable_policy_spec.rb b/spec/policies/issuable_policy_spec.rb
index 20eb09e11c9..86b04ccda57 100644
--- a/spec/policies/issuable_policy_spec.rb
+++ b/spec/policies/issuable_policy_spec.rb
@@ -40,8 +40,8 @@ RSpec.describe IssuablePolicy, models: true do
let(:issue) { create(:issue, project: project, discussion_locked: true) }
context 'when the user is not a project member' do
- it 'can not create a note' do
- expect(policies).to be_disallowed(:create_note)
+ it 'can not create a note nor award emojis' do
+ expect(policies).to be_disallowed(:create_note, :award_emoji)
end
end
@@ -50,8 +50,8 @@ RSpec.describe IssuablePolicy, models: true do
project.add_guest(user)
end
- it 'can create a note' do
- expect(policies).to be_allowed(:create_note)
+ it 'can create a note and award emojis' do
+ expect(policies).to be_allowed(:create_note, :award_emoji)
end
end
end
diff --git a/spec/policies/metrics/dashboard/annotation_policy_spec.rb b/spec/policies/metrics/dashboard/annotation_policy_spec.rb
index 0c59b39ae3e..9ea9f843f2c 100644
--- a/spec/policies/metrics/dashboard/annotation_policy_spec.rb
+++ b/spec/policies/metrics/dashboard/annotation_policy_spec.rb
@@ -3,6 +3,10 @@
require 'spec_helper'
RSpec.describe Metrics::Dashboard::AnnotationPolicy, :models do
+ let(:policy) { described_class.new(user, annotation) }
+
+ let_it_be(:user) { create(:user) }
+
shared_examples 'metrics dashboard annotation policy' do
context 'when guest' do
before do
@@ -51,23 +55,21 @@ RSpec.describe Metrics::Dashboard::AnnotationPolicy, :models do
describe 'rules' do
context 'environments annotation' do
- let(:annotation) { create(:metrics_dashboard_annotation, environment: environment) }
- let(:environment) { create(:environment) }
- let!(:project) { environment.project }
- let(:user) { create(:user) }
- let(:policy) { described_class.new(user, annotation) }
+ let_it_be(:environment) { create(:environment) }
+ let_it_be(:annotation) { create(:metrics_dashboard_annotation, environment: environment) }
- it_behaves_like 'metrics dashboard annotation policy'
+ it_behaves_like 'metrics dashboard annotation policy' do
+ let(:project) { environment.project }
+ end
end
context 'cluster annotation' do
- let(:annotation) { create(:metrics_dashboard_annotation, environment: nil, cluster: cluster) }
- let(:cluster) { create(:cluster, :project) }
- let(:project) { cluster.project }
- let(:user) { create(:user) }
- let(:policy) { described_class.new(user, annotation) }
+ let_it_be(:cluster) { create(:cluster, :project) }
+ let_it_be(:annotation) { create(:metrics_dashboard_annotation, environment: nil, cluster: cluster) }
- it_behaves_like 'metrics dashboard annotation policy'
+ it_behaves_like 'metrics dashboard annotation policy' do
+ let(:project) { cluster.project }
+ end
end
end
end
diff --git a/spec/policies/namespace_policy_spec.rb b/spec/policies/namespace_policy_spec.rb
index f2f411e48d6..8f71cf114c3 100644
--- a/spec/policies/namespace_policy_spec.rb
+++ b/spec/policies/namespace_policy_spec.rb
@@ -48,4 +48,30 @@ RSpec.describe NamespacePolicy do
it { is_expected.to be_disallowed(*owner_permissions) }
end
end
+
+ describe 'create_jira_connect_subscription' do
+ context 'admin' do
+ let(:current_user) { build_stubbed(:admin) }
+
+ context 'when admin mode enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'when admin mode disabled' do
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+ end
+
+ context 'owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'other user' do
+ let(:current_user) { build_stubbed(:user) }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+ end
end
diff --git a/spec/policies/personal_access_token_policy_spec.rb b/spec/policies/personal_access_token_policy_spec.rb
index 71795202e13..b5e8d40b133 100644
--- a/spec/policies/personal_access_token_policy_spec.rb
+++ b/spec/policies/personal_access_token_policy_spec.rb
@@ -8,17 +8,17 @@ RSpec.describe PersonalAccessTokenPolicy do
subject { described_class.new(current_user, token) }
context 'current_user is an administrator', :enable_admin_mode do
- let_it_be(:current_user) { build(:admin) }
+ let_it_be(:current_user) { build_stubbed(:admin) }
context 'not the owner of the token' do
- let_it_be(:token) { build(:personal_access_token) }
+ let_it_be(:token) { build_stubbed(:personal_access_token) }
it { is_expected.to be_allowed(:read_token) }
it { is_expected.to be_allowed(:revoke_token) }
end
context 'owner of the token' do
- let_it_be(:token) { build(:personal_access_token, user: current_user) }
+ let_it_be(:token) { build_stubbed(:personal_access_token, user: current_user) }
it { is_expected.to be_allowed(:read_token) }
it { is_expected.to be_allowed(:revoke_token) }
@@ -26,17 +26,17 @@ RSpec.describe PersonalAccessTokenPolicy do
end
context 'current_user is not an administrator' do
- let_it_be(:current_user) { build(:user) }
+ let_it_be(:current_user) { build_stubbed(:user) }
context 'not the owner of the token' do
- let_it_be(:token) { build(:personal_access_token) }
+ let_it_be(:token) { build_stubbed(:personal_access_token) }
it { is_expected.to be_disallowed(:read_token) }
it { is_expected.to be_disallowed(:revoke_token) }
end
context 'owner of the token' do
- let_it_be(:token) { build(:personal_access_token, user: current_user) }
+ let_it_be(:token) { build_stubbed(:personal_access_token, user: current_user) }
it { is_expected.to be_allowed(:read_token) }
it { is_expected.to be_allowed(:revoke_token) }
@@ -44,17 +44,17 @@ RSpec.describe PersonalAccessTokenPolicy do
end
context 'current_user is a blocked administrator', :enable_admin_mode do
- let_it_be(:current_user) { build(:admin, :blocked) }
+ let_it_be(:current_user) { create(:admin, :blocked) }
context 'owner of the token' do
- let_it_be(:token) { build(:personal_access_token, user: current_user) }
+ let_it_be(:token) { build_stubbed(:personal_access_token, user: current_user) }
it { is_expected.to be_disallowed(:read_token) }
it { is_expected.to be_disallowed(:revoke_token) }
end
context 'not the owner of the token' do
- let_it_be(:token) { build(:personal_access_token) }
+ let_it_be(:token) { build_stubbed(:personal_access_token) }
it { is_expected.to be_disallowed(:read_token) }
it { is_expected.to be_disallowed(:revoke_token) }
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index 9879fc53461..0c457148b4d 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -5,93 +5,10 @@ require 'spec_helper'
RSpec.describe ProjectPolicy do
include ExternalAuthorizationServiceHelpers
include_context 'ProjectPolicy context'
- let_it_be(:other_user) { create(:user) }
- 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(:admin) }
- let(:project) { create(:project, :public, namespace: owner.namespace) }
-
- let(:base_guest_permissions) do
- %i[
- read_project read_board read_list read_wiki read_issue
- read_project_for_iids read_issue_iid read_label
- read_milestone read_snippet read_project_member read_note
- create_project create_issue create_note upload_file create_merge_request_in
- award_emoji read_release read_issue_link
- ]
- end
-
- let(:base_reporter_permissions) do
- %i[
- download_code fork_project create_snippet update_issue
- admin_issue admin_label admin_list read_commit_status read_build
- read_container_image read_pipeline read_environment read_deployment
- read_merge_request download_wiki_code read_sentry_issue read_metrics_dashboard_annotation
- metrics_dashboard read_confidential_issues admin_issue_link
- ]
- end
-
- let(:team_member_reporter_permissions) do
- %i[build_download_code build_read_container_image]
- end
-
- let(:developer_permissions) do
- %i[
- admin_tag admin_milestone admin_merge_request update_merge_request create_commit_status
- update_commit_status create_build update_build create_pipeline
- update_pipeline create_merge_request_from create_wiki push_code
- resolve_note create_container_image update_container_image destroy_container_image daily_statistics
- create_environment update_environment create_deployment update_deployment create_release update_release
- create_metrics_dashboard_annotation delete_metrics_dashboard_annotation update_metrics_dashboard_annotation
- read_terraform_state read_pod_logs
- ]
- end
-
- let(:base_maintainer_permissions) do
- %i[
- push_to_delete_protected_branch update_snippet
- admin_snippet admin_project_member admin_note admin_wiki admin_project
- admin_commit_status admin_build admin_container_image
- admin_pipeline admin_environment admin_deployment destroy_release add_cluster
- read_deploy_token create_deploy_token destroy_deploy_token
- admin_terraform_state
- ]
- end
- let(:public_permissions) do
- %i[
- download_code fork_project read_commit_status read_pipeline
- read_container_image build_download_code build_read_container_image
- download_wiki_code read_release
- ]
- end
-
- let(:owner_permissions) do
- %i[
- change_namespace change_visibility_level rename_project remove_project
- archive_project remove_fork_project destroy_merge_request destroy_issue
- set_issue_iid set_issue_created_at set_issue_updated_at set_note_created_at
- ]
- end
+ let(:project) { public_project }
- # Used in EE specs
- let(:additional_guest_permissions) { [] }
- let(:additional_reporter_permissions) { [] }
- let(:additional_maintainer_permissions) { [] }
-
- let(:guest_permissions) { base_guest_permissions + additional_guest_permissions }
- let(:reporter_permissions) { base_reporter_permissions + additional_reporter_permissions }
- let(:maintainer_permissions) { base_maintainer_permissions + additional_maintainer_permissions }
-
- before do
- project.add_guest(guest)
- project.add_maintainer(maintainer)
- project.add_developer(developer)
- project.add_reporter(reporter)
- end
+ subject { described_class.new(current_user, project) }
def expect_allowed(*permissions)
permissions.each { |p| is_expected.to be_allowed(p) }
@@ -102,7 +19,7 @@ RSpec.describe ProjectPolicy do
end
context 'with no project feature' do
- subject { described_class.new(owner, project) }
+ let(:current_user) { owner }
before do
project.project_feature.destroy!
@@ -134,7 +51,7 @@ RSpec.describe ProjectPolicy do
end
context 'issues feature' do
- subject { described_class.new(owner, project) }
+ let(:current_user) { owner }
context 'when the feature is disabled' do
before do
@@ -162,7 +79,7 @@ RSpec.describe ProjectPolicy do
end
context 'merge requests feature' do
- subject { described_class.new(owner, project) }
+ let(:current_user) { owner }
it 'disallows all permissions when the feature is disabled' do
project.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED)
@@ -176,9 +93,8 @@ RSpec.describe ProjectPolicy do
end
context 'for a guest in a private project' do
- let(:project) { create(:project, :private) }
-
- subject { described_class.new(guest, project) }
+ let(:current_user) { guest }
+ let(:project) { private_project }
it 'disallows the guest from reading the merge request and merge request iid' do
expect_disallowed(:read_merge_request)
@@ -187,12 +103,10 @@ RSpec.describe ProjectPolicy do
end
context 'pipeline feature' do
- let(:project) { create(:project) }
+ let(:project) { private_project }
describe 'for unconfirmed user' do
- let(:unconfirmed_user) { create(:user, confirmed_at: nil) }
-
- subject { described_class.new(unconfirmed_user, project) }
+ let(:current_user) { create(:user, confirmed_at: nil) }
it 'disallows to modify pipelines' do
expect_disallowed(:create_pipeline)
@@ -202,7 +116,7 @@ RSpec.describe ProjectPolicy do
end
describe 'for confirmed user' do
- subject { described_class.new(developer, project) }
+ let(:current_user) { developer }
it 'allows modify pipelines' do
expect_allowed(:create_pipeline)
@@ -214,7 +128,7 @@ RSpec.describe ProjectPolicy do
context 'builds feature' do
context 'when builds are disabled' do
- subject { described_class.new(owner, project) }
+ let(:current_user) { owner }
before do
project.project_feature.update!(builds_access_level: ProjectFeature::DISABLED)
@@ -234,7 +148,7 @@ RSpec.describe ProjectPolicy do
end
context 'when builds are disabled only for some users' do
- subject { described_class.new(guest, project) }
+ let(:current_user) { guest }
before do
project.project_feature.update!(builds_access_level: ProjectFeature::PRIVATE)
@@ -265,7 +179,7 @@ RSpec.describe ProjectPolicy do
end
context 'when user is a project member' do
- subject { described_class.new(owner, project) }
+ let(:current_user) { owner }
context 'when it is disabled' do
before do
@@ -283,8 +197,8 @@ RSpec.describe ProjectPolicy do
end
end
- context 'when user is some other user' do
- subject { described_class.new(other_user, project) }
+ context 'when user is non-member' do
+ let(:current_user) { non_member }
context 'when access level is private' do
before do
@@ -314,7 +228,7 @@ RSpec.describe ProjectPolicy do
context 'when a public project has merge requests allowing access' do
include ProjectForksHelper
- let(:user) { create(:user) }
+ let(:current_user) { create(:user) }
let(:target_project) { create(:project, :public) }
let(:project) { fork_project(target_project) }
let!(:merge_request) do
@@ -330,20 +244,18 @@ RSpec.describe ProjectPolicy do
%w(create_build create_pipeline)
end
- subject { described_class.new(user, project) }
-
it 'does not allow pushing code' do
expect_disallowed(*maintainer_abilities)
end
it 'allows pushing if the user is a member with push access to the target project' do
- target_project.add_developer(user)
+ target_project.add_developer(current_user)
expect_allowed(*maintainer_abilities)
end
it 'disallows abilities to a maintainer if the merge request was closed' do
- target_project.add_developer(user)
+ target_project.add_developer(current_user)
merge_request.close!
expect_disallowed(*maintainer_abilities)
@@ -351,12 +263,9 @@ RSpec.describe ProjectPolicy do
end
it_behaves_like 'clusterable policies' do
- let(:clusterable) { create(:project, :repository) }
- let(:cluster) do
- create(:cluster,
- :provided_by_gcp,
- :project,
- projects: [clusterable])
+ let_it_be(:clusterable) { create(:project, :repository) }
+ let_it_be(:cluster) do
+ create(:cluster, :provided_by_gcp, :project, projects: [clusterable])
end
end
@@ -427,16 +336,14 @@ RSpec.describe ProjectPolicy do
end
context 'forking a project' do
- subject { described_class.new(current_user, project) }
-
context 'anonymous user' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:fork_project) }
end
context 'project member' do
- let_it_be(:project) { create(:project, :private) }
+ let(:project) { private_project }
context 'guest' do
let(:current_user) { guest }
@@ -455,10 +362,8 @@ RSpec.describe ProjectPolicy do
end
describe 'update_max_artifacts_size' do
- subject { described_class.new(current_user, project) }
-
context 'when no user' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { expect_disallowed(:update_max_artifacts_size) }
end
@@ -487,12 +392,10 @@ RSpec.describe ProjectPolicy do
context 'alert bot' do
let(:current_user) { User.alert_bot }
- subject { described_class.new(current_user, project) }
-
it { is_expected.to be_allowed(:reporter_access) }
context 'within a private project' do
- let(:project) { create(:project, :private) }
+ let(:project) { private_project }
it { is_expected.to be_allowed(:admin_issue) }
end
@@ -501,8 +404,6 @@ RSpec.describe ProjectPolicy do
context 'support bot' do
let(:current_user) { User.support_bot }
- subject { described_class.new(current_user, project) }
-
context 'with service desk disabled' do
it { expect_allowed(:guest_access) }
it { expect_disallowed(:create_note, :read_project) }
@@ -526,8 +427,6 @@ RSpec.describe ProjectPolicy do
end
describe 'read_prometheus_alerts' do
- subject { described_class.new(current_user, project) }
-
context 'with admin' do
let(:current_user) { admin }
@@ -571,17 +470,15 @@ RSpec.describe ProjectPolicy do
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:read_prometheus_alerts) }
end
end
describe 'metrics_dashboard feature' do
- subject { described_class.new(current_user, project) }
-
context 'public project' do
- let(:project) { create(:project, :public) }
+ let(:project) { public_project }
context 'feature private' do
context 'with reporter' do
@@ -601,7 +498,7 @@ RSpec.describe ProjectPolicy do
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard) }
end
@@ -633,7 +530,7 @@ RSpec.describe ProjectPolicy do
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_allowed(:metrics_dashboard) }
it { is_expected.to be_allowed(:read_prometheus) }
@@ -645,7 +542,7 @@ RSpec.describe ProjectPolicy do
end
context 'internal project' do
- let(:project) { create(:project, :internal) }
+ let(:project) { internal_project }
context 'feature private' do
context 'with reporter' do
@@ -665,7 +562,7 @@ RSpec.describe ProjectPolicy do
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard)}
end
@@ -697,7 +594,7 @@ RSpec.describe ProjectPolicy do
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard) }
end
@@ -705,7 +602,7 @@ RSpec.describe ProjectPolicy do
end
context 'private project' do
- let(:project) { create(:project, :private) }
+ let(:project) { private_project }
context 'feature private' do
context 'with reporter' do
@@ -725,7 +622,7 @@ RSpec.describe ProjectPolicy do
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard) }
end
@@ -749,7 +646,7 @@ RSpec.describe ProjectPolicy do
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard) }
end
@@ -774,7 +671,7 @@ RSpec.describe ProjectPolicy do
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard) }
end
@@ -806,8 +703,6 @@ RSpec.describe ProjectPolicy do
end
describe 'create_web_ide_terminal' do
- subject { described_class.new(current_user, project) }
-
context 'with admin' do
let(:current_user) { admin }
@@ -851,20 +746,20 @@ RSpec.describe ProjectPolicy do
end
context 'with non member' do
- let(:current_user) { create(:user) }
+ let(:current_user) { non_member }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
end
describe 'read_repository_graphs' do
- subject { described_class.new(guest, project) }
+ let(:current_user) { guest }
before do
allow(subject).to receive(:allowed?).with(:read_repository_graphs).and_call_original
@@ -885,7 +780,7 @@ RSpec.describe ProjectPolicy do
end
describe 'design permissions' do
- subject { described_class.new(guest, project) }
+ let(:current_user) { guest }
let(:design_permissions) do
%i[read_design_activity read_design]
@@ -907,7 +802,7 @@ RSpec.describe ProjectPolicy do
end
describe 'read_build_report_results' do
- subject { described_class.new(guest, project) }
+ let(:current_user) { guest }
before do
allow(subject).to receive(:allowed?).with(:read_build_report_results).and_call_original
@@ -945,8 +840,6 @@ RSpec.describe ProjectPolicy do
end
describe 'read_package' do
- subject { described_class.new(current_user, project) }
-
context 'with admin' do
let(:current_user) { admin }
@@ -997,15 +890,55 @@ RSpec.describe ProjectPolicy do
end
context 'with non member' do
- let(:current_user) { create(:user) }
+ let(:current_user) { non_member }
it { is_expected.to be_allowed(:read_package) }
end
context 'with anonymous' do
- let(:current_user) { nil }
+ let(:current_user) { anonymous }
it { is_expected.to be_allowed(:read_package) }
end
end
+
+ describe 'read_feature_flag' do
+ subject { described_class.new(current_user, project) }
+
+ context 'with maintainer' do
+ let(:current_user) { maintainer }
+
+ context 'when repository is available' do
+ it { is_expected.to be_allowed(:read_feature_flag) }
+ end
+
+ context 'when repository is disabled' do
+ before do
+ project.project_feature.update!(
+ merge_requests_access_level: ProjectFeature::DISABLED,
+ builds_access_level: ProjectFeature::DISABLED,
+ repository_access_level: ProjectFeature::DISABLED
+ )
+ end
+
+ it { is_expected.to be_disallowed(:read_feature_flag) }
+ end
+ end
+
+ context 'with developer' do
+ let(:current_user) { developer }
+
+ context 'when repository is available' do
+ it { is_expected.to be_allowed(:read_feature_flag) }
+ end
+ end
+
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ context 'when repository is available' do
+ it { is_expected.to be_disallowed(:read_feature_flag) }
+ end
+ end
+ end
end
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index d7338622c86..38641558b6b 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -82,4 +82,24 @@ RSpec.describe UserPolicy do
describe "updating a user" do
it_behaves_like 'changing a user', :update_user
end
+
+ describe 'disabling two-factor authentication' do
+ context 'disabling their own two-factor authentication' do
+ let(:user) { current_user }
+
+ it { is_expected.to be_allowed(:disable_two_factor) }
+ end
+
+ context 'disabling the two-factor authentication of another user' do
+ context 'when the executor is an admin', :enable_admin_mode do
+ let(:current_user) { create(:user, :admin) }
+
+ it { is_expected.to be_allowed(:disable_two_factor) }
+ end
+
+ context 'when the executor is not an admin' do
+ it { is_expected.not_to be_allowed(:disable_two_factor) }
+ end
+ end
+ end
end
diff --git a/spec/presenters/alert_management/alert_presenter_spec.rb b/spec/presenters/alert_management/alert_presenter_spec.rb
index 394007a802f..243301502ce 100644
--- a/spec/presenters/alert_management/alert_presenter_spec.rb
+++ b/spec/presenters/alert_management/alert_presenter_spec.rb
@@ -4,58 +4,117 @@ require 'spec_helper'
RSpec.describe AlertManagement::AlertPresenter do
let_it_be(:project) { create(:project) }
-
- let_it_be(:generic_payload) do
+ let_it_be(:payload) do
{
'title' => 'Alert title',
'start_time' => '2020-04-27T10:10:22.265949279Z',
- 'custom' => { 'param' => 73 },
- 'runbook' => 'https://runbook.com'
+ 'custom' => {
+ 'alert' => {
+ 'fields' => %w[one two]
+ }
+ },
+ 'yet' => {
+ 'another' => 73
+ }
}
end
- let_it_be(:alert) do
- create(:alert_management_alert, :with_description, :with_host, :with_service, :with_monitoring_tool, project: project, payload: generic_payload)
- end
-
+ let_it_be(:alert) { create(:alert_management_alert, project: project, payload: payload) }
let(:alert_url) { "http://localhost/#{project.full_path}/-/alert_management/#{alert.iid}/details" }
subject(:presenter) { described_class.new(alert) }
describe '#issue_description' do
+ let_it_be(:alert) { create(:alert_management_alert, project: project, payload: {}) }
+
let(:markdown_line_break) { ' ' }
- it 'returns an alert issue description' do
- expect(presenter.issue_description).to eq(
- <<~MARKDOWN.chomp
- #### Summary
+ subject { presenter.issue_description }
- **Start time:** #{presenter.start_time}#{markdown_line_break}
- **Severity:** #{presenter.severity}#{markdown_line_break}
- **Service:** #{alert.service}#{markdown_line_break}
- **Monitoring tool:** #{alert.monitoring_tool}#{markdown_line_break}
- **Hosts:** #{alert.hosts.join(' ')}#{markdown_line_break}
- **Description:** #{alert.description}#{markdown_line_break}
- **GitLab alert:** #{alert_url}
+ context 'with an empty payload' do
+ it do
+ is_expected.to eq(
+ <<~MARKDOWN.chomp
+ **Start time:** #{presenter.start_time}#{markdown_line_break}
+ **Severity:** #{presenter.severity}#{markdown_line_break}
+ **GitLab alert:** #{alert_url}
- #### Alert Details
+ MARKDOWN
+ )
+ end
+ end
- **custom.param:** 73#{markdown_line_break}
- **runbook:** https://runbook.com
- MARKDOWN
- )
+ context 'with optional alert attributes' do
+ let_it_be(:alert) do
+ create(:alert_management_alert, :with_description, :with_host, :with_service, :with_monitoring_tool, project: project, payload: payload)
+ end
+
+ before do
+ allow(alert.parsed_payload).to receive(:full_query).and_return('metric > 1')
+ end
+
+ it do
+ is_expected.to eq(
+ <<~MARKDOWN.chomp
+ **Start time:** #{presenter.start_time}#{markdown_line_break}
+ **Severity:** #{presenter.severity}#{markdown_line_break}
+ **full_query:** `metric > 1`#{markdown_line_break}
+ **Service:** #{alert.service}#{markdown_line_break}
+ **Monitoring tool:** #{alert.monitoring_tool}#{markdown_line_break}
+ **Hosts:** #{alert.hosts.join(' ')}#{markdown_line_break}
+ **Description:** #{alert.description}#{markdown_line_break}
+ **GitLab alert:** #{alert_url}
+
+ MARKDOWN
+ )
+ end
end
- end
- describe '#metrics_dashboard_url' do
- it 'is not defined' do
- expect(presenter.metrics_dashboard_url).to be_nil
+ context 'with incident markdown' do
+ before do
+ allow(alert.parsed_payload).to receive(:alert_markdown).and_return('**`markdown example`**')
+ end
+
+ it do
+ is_expected.to eq(
+ <<~MARKDOWN.chomp
+ **Start time:** #{presenter.start_time}#{markdown_line_break}
+ **Severity:** #{presenter.severity}#{markdown_line_break}
+ **GitLab alert:** #{alert_url}
+
+
+ ---
+
+ **`markdown example`**
+ MARKDOWN
+ )
+ end
+ end
+
+ context 'with metrics_dashboard_url' do
+ before do
+ allow(alert.parsed_payload).to receive(:metrics_dashboard_url).and_return('https://gitlab.com/metrics')
+ end
+
+ it do
+ is_expected.to eq(
+ <<~MARKDOWN.chomp
+ **Start time:** #{presenter.start_time}#{markdown_line_break}
+ **Severity:** #{presenter.severity}#{markdown_line_break}
+ **GitLab alert:** #{alert_url}
+
+ [](https://gitlab.com/metrics)
+ MARKDOWN
+ )
+ end
end
end
- describe '#runbook' do
- it 'shows the runbook from the payload' do
- expect(presenter.runbook).to eq('https://runbook.com')
+ describe '#start_time' do
+ it 'formats the start time of the alert' do
+ alert.started_at = Time.utc(2019, 5, 5)
+
+ expect(presenter.start_time). to eq('05 May 2019, 12:00AM (UTC)')
end
end
@@ -64,4 +123,17 @@ RSpec.describe AlertManagement::AlertPresenter do
expect(presenter.details_url).to match(%r{#{project.web_url}/-/alert_management/#{alert.iid}/details})
end
end
+
+ describe '#details' do
+ subject { presenter.details }
+
+ it 'renders the payload as inline hash' do
+ is_expected.to eq(
+ 'title' => 'Alert title',
+ 'start_time' => '2020-04-27T10:10:22.265949279Z',
+ 'custom.alert.fields' => %w[one two],
+ 'yet.another' => 73
+ )
+ end
+ end
end
diff --git a/spec/presenters/alert_management/prometheus_alert_presenter_spec.rb b/spec/presenters/alert_management/prometheus_alert_presenter_spec.rb
deleted file mode 100644
index 3cfff3c1b2f..00000000000
--- a/spec/presenters/alert_management/prometheus_alert_presenter_spec.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe AlertManagement::PrometheusAlertPresenter do
- let_it_be(:project) { create(:project) }
- let(:payload) do
- {
- 'annotations' => {
- 'title' => 'Alert title',
- 'gitlab_incident_markdown' => '**`markdown example`**',
- 'custom annotation' => 'custom annotation value'
- },
- 'startsAt' => '2020-04-27T10:10:22.265949279Z',
- 'generatorURL' => 'http://8d467bd4607a:9090/graph?g0.expr=vector%281%29&g0.tab=1'
- }
- end
-
- let!(:alert) do
- create(:alert_management_alert, :prometheus, project: project, payload: payload)
- end
-
- let(:alert_url) { "http://localhost/#{project.full_path}/-/alert_management/#{alert.iid}/details" }
-
- subject(:presenter) { described_class.new(alert) }
-
- describe '#issue_description' do
- let(:markdown_line_break) { ' ' }
-
- it 'returns an alert issue description' do
- expect(presenter.issue_description).to eq(
- <<~MARKDOWN.chomp
- #### Summary
-
- **Start time:** #{presenter.start_time}#{markdown_line_break}
- **Severity:** #{presenter.severity}#{markdown_line_break}
- **full_query:** `vector(1)`#{markdown_line_break}
- **Monitoring tool:** Prometheus#{markdown_line_break}
- **GitLab alert:** #{alert_url}
-
- #### Alert Details
-
- **custom annotation:** custom annotation value
-
- ---
-
- **`markdown example`**
- MARKDOWN
- )
- end
- end
-
- describe '#metrics_dashboard_url' do
- subject { presenter.metrics_dashboard_url }
-
- context 'for a non-prometheus alert' do
- it { is_expected.to be_nil }
- end
-
- context 'for a self-managed prometheus alert' do
- include_context 'self-managed prometheus alert attributes'
-
- it { is_expected.to eq(dashboard_url_for_alert) }
- end
-
- context 'for a gitlab-managed prometheus alert' do
- include_context 'gitlab-managed prometheus alert attributes'
-
- it { is_expected.to eq(dashboard_url_for_alert) }
- end
- end
-
- describe '#runbook' do
- subject { presenter.runbook }
-
- it { is_expected.to be_nil }
-
- context 'with runbook in payload' do
- let(:expected_runbook) { 'https://awesome-runbook.com' }
- let(:payload) { { 'annotations' => { 'runbook' => expected_runbook } } }
-
- it { is_expected.to eq(expected_runbook) }
- end
- end
-end
diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb
index 8d302b242b3..1ff2ea3d225 100644
--- a/spec/presenters/ci/build_presenter_spec.rb
+++ b/spec/presenters/ci/build_presenter_spec.rb
@@ -228,7 +228,7 @@ RSpec.describe Ci::BuildPresenter do
let(:build) { create(:ci_build, :scheduled) }
it 'returns execution time' do
- Timecop.freeze do
+ freeze_time do
is_expected.to be_like_time(60.0)
end
end
@@ -238,7 +238,7 @@ RSpec.describe Ci::BuildPresenter do
let(:build) { create(:ci_build, :expired_scheduled) }
it 'returns execution time' do
- Timecop.freeze do
+ freeze_time do
is_expected.to eq(0)
end
end
@@ -249,7 +249,7 @@ RSpec.describe Ci::BuildPresenter do
let(:build) { create(:ci_build) }
it 'does not return execution time' do
- Timecop.freeze do
+ freeze_time do
is_expected.to be_falsy
end
end
diff --git a/spec/presenters/ci/pipeline_artifacts/code_coverage_presenter_spec.rb b/spec/presenters/ci/pipeline_artifacts/code_coverage_presenter_spec.rb
new file mode 100644
index 00000000000..e679f5fa144
--- /dev/null
+++ b/spec/presenters/ci/pipeline_artifacts/code_coverage_presenter_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::PipelineArtifacts::CodeCoveragePresenter do
+ let(:pipeline_artifact) { create(:ci_pipeline_artifact, :with_code_coverage_with_multiple_files) }
+
+ subject(:presenter) { described_class.new(pipeline_artifact) }
+
+ describe '#for_files' do
+ subject { presenter.for_files(filenames) }
+
+ context 'when code coverage has data' do
+ context 'when filenames is empty' do
+ let(:filenames) { %w() }
+
+ it 'returns hash without coverage' do
+ expect(subject).to match(files: {})
+ end
+ end
+
+ context 'when filenames do not match code coverage data' do
+ let(:filenames) { %w(demo.rb) }
+
+ it 'returns hash without coverage' do
+ expect(subject).to match(files: {})
+ end
+ end
+
+ context 'when filenames matches code coverage data' do
+ context 'when asking for one filename' do
+ let(:filenames) { %w(file_a.rb) }
+
+ it 'returns coverage for the given filename' do
+ expect(subject).to match(files: { "file_a.rb" => { "1" => 1, "2" => 1, "3" => 1 } })
+ end
+ end
+
+ context 'when asking for multiple filenames' do
+ let(:filenames) { %w(file_a.rb file_b.rb) }
+
+ it 'returns coverage for a the given filenames' do
+ expect(subject).to match(
+ files: {
+ "file_a.rb" => {
+ "1" => 1,
+ "2" => 1,
+ "3" => 1
+ },
+ "file_b.rb" => {
+ "1" => 0,
+ "2" => 0,
+ "3" => 0
+ }
+ }
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/presenters/ci/pipeline_presenter_spec.rb b/spec/presenters/ci/pipeline_presenter_spec.rb
index 158daad97f5..18f79bc930c 100644
--- a/spec/presenters/ci/pipeline_presenter_spec.rb
+++ b/spec/presenters/ci/pipeline_presenter_spec.rb
@@ -65,7 +65,7 @@ RSpec.describe Ci::PipelinePresenter do
describe '#failure_reason' do
context 'when pipeline has a failure reason' do
- ::Ci::PipelineEnums.failure_reasons.keys.each do |failure_reason|
+ Enums::Ci::Pipeline.failure_reasons.keys.each do |failure_reason|
context "when failure reason is #{failure_reason}" do
before do
pipeline.failure_reason = failure_reason
diff --git a/spec/presenters/clusters/cluster_presenter_spec.rb b/spec/presenters/clusters/cluster_presenter_spec.rb
index e99b04fda8d..2d38c91499a 100644
--- a/spec/presenters/clusters/cluster_presenter_spec.rb
+++ b/spec/presenters/clusters/cluster_presenter_spec.rb
@@ -264,7 +264,7 @@ RSpec.describe Clusters::ClusterPresenter do
it do
is_expected.to include('clusters-path': clusterable_presenter.index_path,
'dashboard-endpoint': clusterable_presenter.metrics_dashboard_path(cluster),
- 'documentation-path': help_page_path('user/project/clusters/index', anchor: 'monitoring-your-kubernetes-cluster-ultimate'),
+ 'documentation-path': help_page_path('user/project/clusters/index', anchor: 'monitoring-your-kubernetes-cluster'),
'add-dashboard-documentation-path': help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'),
'empty-getting-started-svg-path': match_asset_path('/assets/illustrations/monitoring/getting_started.svg'),
'empty-loading-svg-path': match_asset_path('/assets/illustrations/monitoring/loading.svg'),
diff --git a/spec/presenters/dev_ops_score/metric_presenter_spec.rb b/spec/presenters/dev_ops_report/metric_presenter_spec.rb
index 8b7b2c88578..306b5592c33 100644
--- a/spec/presenters/dev_ops_score/metric_presenter_spec.rb
+++ b/spec/presenters/dev_ops_report/metric_presenter_spec.rb
@@ -2,10 +2,10 @@
require 'spec_helper'
-RSpec.describe DevOpsScore::MetricPresenter do
+RSpec.describe DevOpsReport::MetricPresenter do
subject { described_class.new(metric) }
- let(:metric) { build(:dev_ops_score_metric) }
+ let(:metric) { build(:dev_ops_report_metric) }
describe '#cards' do
it 'includes instance score, leader score and percentage score' do
diff --git a/spec/presenters/packages/conan/package_presenter_spec.rb b/spec/presenters/packages/conan/package_presenter_spec.rb
index 3bc649c5da4..4e8af752f3e 100644
--- a/spec/presenters/packages/conan/package_presenter_spec.rb
+++ b/spec/presenters/packages/conan/package_presenter_spec.rb
@@ -4,34 +4,39 @@ require 'spec_helper'
RSpec.describe ::Packages::Conan::PackagePresenter do
let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
+ let_it_be(:package) { create(:conan_package) }
+ let_it_be(:project) { package.project }
let_it_be(:conan_package_reference) { '123456789'}
+ let(:params) { { package_scope: :instance } }
- RSpec.shared_examples 'not selecting a package with the wrong type' do
- context 'with a nuget package with same name and version' do
- let_it_be(:wrong_package) { create(:nuget_package, name: 'wrong', version: '1.0.0', project: project) }
-
- let(:recipe) { "#{wrong_package.name}/#{wrong_package.version}" }
+ shared_examples 'no existing package' do
+ context 'when package does not exist' do
+ let(:package) { nil }
it { is_expected.to be_empty }
end
end
- describe '#recipe_urls' do
- subject { described_class.new(recipe, user, project).recipe_urls }
-
- context 'no existing package' do
- let(:recipe) { "my-pkg/v1.0.0/#{project.full_path}/stable" }
+ shared_examples 'conan_file_metadatum is not found' do
+ context 'when no conan_file_metadatum exists' do
+ before do
+ package.package_files.each do |file|
+ file.conan_file_metadatum.delete
+ file.reload
+ end
+ end
it { is_expected.to be_empty }
end
+ end
- it_behaves_like 'not selecting a package with the wrong type'
+ describe '#recipe_urls' do
+ subject { described_class.new(package, user, project, params).recipe_urls }
- context 'existing package' do
- let(:package) { create(:conan_package, project: project) }
- let(:recipe) { package.conan_recipe }
+ it_behaves_like 'no existing package'
+ it_behaves_like 'conan_file_metadatum is not found'
+ context 'existing package' do
let(:expected_result) do
{
"conanfile.py" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
@@ -40,24 +45,37 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
end
it { is_expected.to eq(expected_result) }
- end
- end
- describe '#recipe_snapshot' do
- subject { described_class.new(recipe, user, project).recipe_snapshot }
+ context 'when there are multiple channels for the same package' do
+ let(:conan_metadatum) { create(:conan_metadatum, package_channel: 'newest' ) }
+ let!(:newest_package) { create(:conan_package, name: package.name, version: package.version, project: project, conan_metadatum: conan_metadatum) }
+
+ it { is_expected.to eq(expected_result) }
+ end
- context 'no existing package' do
- let(:recipe) { "my-pkg/v1.0.0/#{project.full_path}/stable" }
+ context 'with package_scope of project' do
+ # #recipe_file_url checks for params[:id]
+ let(:params) { { id: project.id } }
- it { is_expected.to be_empty }
+ let(:expected_result) do
+ {
+ "conanfile.py" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
+ "conanmanifest.txt" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
+ }
+ end
+
+ it { is_expected.to eq(expected_result) }
+ end
end
+ end
- it_behaves_like 'not selecting a package with the wrong type'
+ describe '#recipe_snapshot' do
+ subject { described_class.new(package, user, project).recipe_snapshot }
- context 'existing package' do
- let(:package) { create(:conan_package, project: project) }
- let(:recipe) { package.conan_recipe }
+ it_behaves_like 'no existing package'
+ it_behaves_like 'conan_file_metadatum is not found'
+ context 'existing package' do
let(:expected_result) do
{
"conanfile.py" => '12345abcde',
@@ -72,24 +90,23 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
describe '#package_urls' do
let(:reference) { conan_package_reference }
+ let(:params) do
+ {
+ conan_package_reference: reference,
+ package_scope: :instance
+ }
+ end
+
subject do
described_class.new(
- recipe, user, project, conan_package_reference: reference
+ package, user, project, params
).package_urls
end
- context 'no existing package' do
- let(:recipe) { "my-pkg/v1.0.0/#{project.full_path}/stable" }
-
- it { is_expected.to be_empty }
- end
-
- it_behaves_like 'not selecting a package with the wrong type'
+ it_behaves_like 'no existing package'
+ it_behaves_like 'conan_file_metadatum is not found'
context 'existing package' do
- let(:package) { create(:conan_package, project: project) }
- let(:recipe) { package.conan_recipe }
-
let(:expected_result) do
{
"conaninfo.txt" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conaninfo.txt",
@@ -100,6 +117,26 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
it { is_expected.to eq(expected_result) }
+ context 'with package_scope of project' do
+ # #package_file_url checks for params[:id]
+ let(:params) do
+ {
+ conan_package_reference: reference,
+ id: project.id
+ }
+ end
+
+ let(:expected_result) do
+ {
+ "conaninfo.txt" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conaninfo.txt",
+ "conanmanifest.txt" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conanmanifest.txt",
+ "conan_package.tgz" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conan_package.tgz"
+ }
+ end
+
+ it { is_expected.to eq(expected_result) }
+ end
+
context 'multiple packages with different references' do
let(:info_file) { create(:conan_package_file, :conan_package_info, package: package) }
let(:manifest_file) { create(:conan_package_file, :conan_package_manifest, package: package) }
@@ -131,7 +168,7 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
it 'returns empty if the reference does not exist' do
result = described_class.new(
- recipe, user, project, conan_package_reference: 'doesnotexist'
+ package, user, project, conan_package_reference: 'doesnotexist'
).package_urls
expect(result).to eq({})
@@ -145,22 +182,14 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
subject do
described_class.new(
- recipe, user, project, conan_package_reference: reference
+ package, user, project, conan_package_reference: reference
).package_snapshot
end
- context 'no existing package' do
- let(:recipe) { "my-pkg/v1.0.0/#{project.full_path}/stable" }
-
- it { is_expected.to be_empty }
- end
-
- it_behaves_like 'not selecting a package with the wrong type'
+ it_behaves_like 'no existing package'
+ it_behaves_like 'conan_file_metadatum is not found'
context 'existing package' do
- let(:package) { create(:conan_package, project: project) }
- let(:recipe) { package.conan_recipe }
-
let(:expected_result) do
{
"conaninfo.txt" => '12345abcde',
diff --git a/spec/presenters/packages/nuget/search_results_presenter_spec.rb b/spec/presenters/packages/nuget/search_results_presenter_spec.rb
index 29ec8579dc1..fed20c8e39d 100644
--- a/spec/presenters/packages/nuget/search_results_presenter_spec.rb
+++ b/spec/presenters/packages/nuget/search_results_presenter_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe Packages::Nuget::SearchResultsPresenter do
expect(package_json[:summary]).to be_blank
expect(package_json[:total_downloads]).to eq 0
expect(package_json[:verified]).to be
- expect(package_json[:version]).to eq VersionSorter.sort(versions).last # rubocop: disable Style/UnneededSort
+ expect(package_json[:version]).to eq VersionSorter.sort(versions).last # rubocop: disable Style/RedundantSort
versions.zip(package_json[:versions]).each do |version, version_json|
expect(version_json[:json_url]).to end_with("#{version}.json")
expect(version_json[:downloads]).to eq 0
diff --git a/spec/presenters/projects/prometheus/alert_presenter_spec.rb b/spec/presenters/projects/prometheus/alert_presenter_spec.rb
index 2d58a7f2cfa..98dba28829e 100644
--- a/spec/presenters/projects/prometheus/alert_presenter_spec.rb
+++ b/spec/presenters/projects/prometheus/alert_presenter_spec.rb
@@ -63,119 +63,62 @@ RSpec.describe Projects::Prometheus::AlertPresenter do
it do
is_expected.to eq(
<<~MARKDOWN.chomp
- #### Summary
-
- **Start time:** #{presenter.start_time}
-
- MARKDOWN
- )
- end
- end
-
- context 'with annotations' do
- before do
- payload['annotations'] = { 'title' => 'Alert Title', 'foo' => 'value1', 'bar' => 'value2' }
- end
-
- it do
- is_expected.to eq(
- <<~MARKDOWN.chomp
- #### Summary
-
**Start time:** #{presenter.start_time}
- #### Alert Details
-
- **foo:** value1#{markdown_line_break}
- **bar:** value2
MARKDOWN
)
end
end
- context 'with full query' do
+ context 'with optional attributes' do
before do
+ payload['annotations'] = {
+ 'title' => 'Alert Title',
+ 'foo' => 'value1',
+ 'bar' => 'value2',
+ 'description' => 'Alert Description',
+ 'monitoring_tool' => 'monitoring_tool_name',
+ 'service' => 'service_name',
+ 'hosts' => ['http://localhost:3000', 'http://localhost:3001']
+ }
payload['generatorURL'] = 'http://host?g0.expr=query'
end
it do
is_expected.to eq(
<<~MARKDOWN.chomp
- #### Summary
-
**Start time:** #{presenter.start_time}#{markdown_line_break}
- **full_query:** `query`
+ **full_query:** `query`#{markdown_line_break}
+ **Service:** service_name#{markdown_line_break}
+ **Monitoring tool:** monitoring_tool_name#{markdown_line_break}
+ **Hosts:** http://localhost:3000 http://localhost:3001
MARKDOWN
)
end
end
- context 'with the Generic Alert parameters' do
- let(:generic_alert_params) do
- {
- 'title' => 'The Generic Alert Title',
- 'description' => 'The Generic Alert Description',
- 'monitoring_tool' => 'monitoring_tool_name',
- 'service' => 'service_name',
- 'hosts' => ['http://localhost:3000', 'http://localhost:3001']
- }
- end
-
+ context 'when hosts is a string' do
before do
- payload['annotations'] = generic_alert_params
+ payload['annotations'] = { 'hosts' => 'http://localhost:3000' }
end
it do
is_expected.to eq(
<<~MARKDOWN.chomp
- #### Summary
-
- **Start time:** #{presenter.start_time}#{markdown_line_break}
- **Service:** service_name#{markdown_line_break}
- **Monitoring tool:** monitoring_tool_name#{markdown_line_break}
- **Hosts:** http://localhost:3000 http://localhost:3001
-
- #### Alert Details
+ **Start time:** #{presenter.start_time}#{markdown_line_break}
+ **Hosts:** http://localhost:3000
- **description:** The Generic Alert Description
MARKDOWN
)
end
-
- context 'when hosts is a string' do
- before do
- payload['annotations'] = { 'hosts' => 'http://localhost:3000' }
- end
-
- it do
- is_expected.to eq(
- <<~MARKDOWN.chomp
- #### Summary
-
- **Start time:** #{presenter.start_time}#{markdown_line_break}
- **Hosts:** http://localhost:3000
-
- MARKDOWN
- )
- end
- end
end
context 'with embedded metrics' do
let(:starts_at) { '2018-03-12T09:06:00Z' }
shared_examples_for 'markdown with metrics embed' do
- let(:expected_markdown) do
- <<~MARKDOWN.chomp
- #### Summary
-
- **Start time:** #{presenter.start_time}#{markdown_line_break}
- **full_query:** `avg(metric) > 1.0`
-
- [](#{presenter.metrics_dashboard_url})
- MARKDOWN
- end
+ let(:embed_regex) { /\n\[\]\(#{Regexp.quote(presenter.metrics_dashboard_url)}\)\z/ }
context 'without a starting time available' do
around do |example|
@@ -186,11 +129,11 @@ RSpec.describe Projects::Prometheus::AlertPresenter do
payload.delete('startsAt')
end
- it { is_expected.to eq(expected_markdown) }
+ it { is_expected.to match(embed_regex) }
end
context 'with a starting time available' do
- it { is_expected.to eq(expected_markdown) }
+ it { is_expected.to match(embed_regex) }
end
end
@@ -220,14 +163,8 @@ RSpec.describe Projects::Prometheus::AlertPresenter do
end
context 'when not enough information is present for an embed' do
- let(:expected_markdown) do
- <<~MARKDOWN.chomp
- #### Summary
-
- **Start time:** #{presenter.start_time}#{markdown_line_break}
- **full_query:** `avg(metric) > 1.0`
-
- MARKDOWN
+ shared_examples_for 'does not include an embed' do
+ it { is_expected.not_to match(/\[\]\(.+\)/) }
end
context 'without title' do
@@ -235,7 +172,7 @@ RSpec.describe Projects::Prometheus::AlertPresenter do
payload['annotations'].delete('title')
end
- it { is_expected.to eq(expected_markdown) }
+ it_behaves_like 'does not include an embed'
end
context 'without environment' do
@@ -243,24 +180,15 @@ RSpec.describe Projects::Prometheus::AlertPresenter do
payload['labels'].delete('gitlab_environment_name')
end
- it { is_expected.to eq(expected_markdown) }
+ it_behaves_like 'does not include an embed'
end
context 'without full_query' do
- let(:expected_markdown) do
- <<~MARKDOWN.chomp
- #### Summary
-
- **Start time:** #{presenter.start_time}
-
- MARKDOWN
- end
-
before do
payload.delete('generatorURL')
end
- it { is_expected.to eq(expected_markdown) }
+ it_behaves_like 'does not include an embed'
end
end
end
diff --git a/spec/requests/api/access_requests_spec.rb b/spec/requests/api/access_requests_spec.rb
index 223d740a004..2af6c438fc9 100644
--- a/spec/requests/api/access_requests_spec.rb
+++ b/spec/requests/api/access_requests_spec.rb
@@ -90,7 +90,7 @@ RSpec.describe API::AccessRequests do
context 'when authenticated as a stranger' do
context "when access request is disabled for the #{source_type}" do
before do
- source.update(request_access_enabled: false)
+ source.update!(request_access_enabled: false)
end
it 'returns 403' do
diff --git a/spec/requests/api/boards_spec.rb b/spec/requests/api/boards_spec.rb
index f0d3afd0af7..a63198c5407 100644
--- a/spec/requests/api/boards_spec.rb
+++ b/spec/requests/api/boards_spec.rb
@@ -41,7 +41,7 @@ RSpec.describe API::Boards do
it 'creates a new issue board list for group labels' do
group = create(:group)
group_label = create(:group_label, group: group)
- board_parent.update(group: group)
+ board_parent.update!(group: group)
post api(url, user), params: { label_id: group_label.id }
@@ -54,7 +54,7 @@ RSpec.describe API::Boards do
group = create(:group)
sub_group = create(:group, parent: group)
group_label = create(:group_label, group: group)
- board_parent.update(group: sub_group)
+ board_parent.update!(group: sub_group)
group.add_developer(user)
sub_group.add_developer(user)
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index 4b9b82b3a5b..5298f93886d 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -181,7 +181,7 @@ RSpec.describe API::Branches do
context 'when unauthenticated', 'and project is public' do
before do
- project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
end
it_behaves_like 'repository branches'
@@ -289,7 +289,7 @@ RSpec.describe API::Branches do
context 'when unauthenticated', 'and project is public' do
before do
- project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
end
it_behaves_like 'repository branch'
diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb
index 111bc933ea4..577b43e6e42 100644
--- a/spec/requests/api/ci/pipelines_spec.rb
+++ b/spec/requests/api/ci/pipelines_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe API::Ci::Pipelines do
let_it_be(:user) { create(:user) }
let_it_be(:non_member) { create(:user) }
+ let_it_be(:project2) { create(:project, creator: user) }
# We need to reload as the shared example 'pipelines visibility table' is changing project
let_it_be(:project, reload: true) do
@@ -307,6 +308,606 @@ RSpec.describe API::Ci::Pipelines do
end
end
+ describe 'GET /projects/:id/pipelines/:pipeline_id/jobs' do
+ let(:query) { {} }
+ let(:api_user) { user }
+ let_it_be(:job) do
+ create(:ci_build, :success, pipeline: pipeline,
+ artifacts_expire_at: 1.day.since)
+ end
+
+ let(:guest) { create(:project_member, :guest, project: project).user }
+
+ before do |example|
+ unless example.metadata[:skip_before_request]
+ project.update!(public_builds: false)
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
+ end
+ end
+
+ context 'with ci_jobs_finder_refactor ff enabled' do
+ before do
+ stub_feature_flags(ci_jobs_finder_refactor: true)
+ end
+
+ context 'authorized user' do
+ it 'returns pipeline jobs' 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
+ 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)
+ expect(json_response.first['artifacts_file']).to be_nil
+ expect(json_response.first['artifacts']).to be_an Array
+ expect(json_response.first['artifacts']).to be_empty
+ end
+
+ it_behaves_like 'a job with artifacts and trace' do
+ let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" }
+ end
+
+ it 'returns pipeline data' do
+ json_job = json_response.first
+
+ expect(json_job['pipeline']).not_to be_empty
+ expect(json_job['pipeline']['id']).to eq job.pipeline.id
+ expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
+ expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
+ expect(json_job['pipeline']['status']).to eq job.pipeline.status
+ end
+
+ context 'filter jobs with one scope element' do
+ let(:query) { { 'scope' => 'pending' } }
+
+ it do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ end
+ end
+
+ context 'filter jobs with hash' do
+ let(:query) { { scope: { hello: 'pending', world: 'running' } } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+
+ context 'filter jobs with array of scope elements' do
+ let(:query) { { scope: %w(pending running) } }
+
+ it do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ end
+ end
+
+ context 'respond 400 when scope contains invalid state' do
+ let(:query) { { scope: %w(unknown running) } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+
+ context 'jobs in different pipelines' do
+ let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
+ let!(:job2) { create(:ci_build, pipeline: pipeline2) }
+
+ it 'excludes jobs from other pipelines' do
+ json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) }
+ end
+ end
+
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
+ end.count
+
+ create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline)
+
+ expect do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
+ end.not_to exceed_all_query_limit(control_count)
+ end
+ end
+
+ context 'no pipeline is found' do
+ it 'does not return jobs' do
+ get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/jobs", user)
+
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'unauthorized user' do
+ context 'when user is not logged in' do
+ let(:api_user) { nil }
+
+ it 'does not return jobs' do
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user is guest' do
+ let(:guest) { create(:project_member, :guest, project: project).user }
+ let(:api_user) { guest }
+
+ it 'does not return jobs' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ context 'with ci_jobs_finder ff disabled' do
+ before do
+ stub_feature_flags(ci_jobs_finder_refactor: false)
+ end
+
+ context 'authorized user' do
+ it 'returns pipeline jobs' 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
+ 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)
+ expect(json_response.first['artifacts_file']).to be_nil
+ expect(json_response.first['artifacts']).to be_an Array
+ expect(json_response.first['artifacts']).to be_empty
+ end
+
+ it_behaves_like 'a job with artifacts and trace' do
+ let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" }
+ end
+
+ it 'returns pipeline data' do
+ json_job = json_response.first
+
+ expect(json_job['pipeline']).not_to be_empty
+ expect(json_job['pipeline']['id']).to eq job.pipeline.id
+ expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
+ expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
+ expect(json_job['pipeline']['status']).to eq job.pipeline.status
+ end
+
+ context 'filter jobs with one scope element' do
+ let(:query) { { 'scope' => 'pending' } }
+
+ it do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ end
+ end
+
+ context 'filter jobs with hash' do
+ let(:query) { { scope: { hello: 'pending', world: 'running' } } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+
+ context 'filter jobs with array of scope elements' do
+ let(:query) { { scope: %w(pending running) } }
+
+ it do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ end
+ end
+
+ context 'respond 400 when scope contains invalid state' do
+ let(:query) { { scope: %w(unknown running) } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+
+ context 'jobs in different pipelines' do
+ let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
+ let!(:job2) { create(:ci_build, pipeline: pipeline2) }
+
+ it 'excludes jobs from other pipelines' do
+ json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) }
+ end
+ end
+
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
+ end.count
+
+ create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline)
+
+ expect do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
+ end.not_to exceed_all_query_limit(control_count)
+ end
+ end
+
+ context 'no pipeline is found' do
+ it 'does not return jobs' do
+ get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/jobs", user)
+
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'unauthorized user' do
+ context 'when user is not logged in' do
+ let(:api_user) { nil }
+
+ it 'does not return jobs' do
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user is guest' do
+ let(:guest) { create(:project_member, :guest, project: project).user }
+ let(:api_user) { guest }
+
+ it 'does not return jobs' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/pipelines/:pipeline_id/bridges' do
+ let_it_be(:bridge) { create(:ci_bridge, pipeline: pipeline) }
+ let(:downstream_pipeline) { create(:ci_pipeline) }
+
+ let!(:pipeline_source) do
+ create(:ci_sources_pipeline,
+ source_pipeline: pipeline,
+ source_project: project,
+ source_job: bridge,
+ pipeline: downstream_pipeline,
+ project: downstream_pipeline.project)
+ end
+
+ let(:query) { {} }
+ let(:api_user) { user }
+
+ before do |example|
+ unless example.metadata[:skip_before_request]
+ project.update!(public_builds: false)
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+ end
+ end
+
+ context 'with ci_jobs_finder_refactor ff enabled' do
+ before do
+ stub_feature_flags(ci_jobs_finder_refactor: true)
+ end
+
+ context 'authorized user' do
+ it 'returns pipeline bridges' 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
+ 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
+ expect(json_response.first['name']).to eq bridge.name
+ expect(json_response.first['stage']).to eq bridge.stage
+ end
+
+ it 'returns pipeline data' do
+ json_bridge = json_response.first
+
+ expect(json_bridge['pipeline']).not_to be_empty
+ expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id
+ expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref
+ expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha
+ expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status
+ end
+
+ it 'returns downstream pipeline data' do
+ json_bridge = json_response.first
+
+ expect(json_bridge['downstream_pipeline']).not_to be_empty
+ expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id
+ expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref
+ expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha
+ expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status
+ end
+
+ context 'filter bridges' do
+ before_all do
+ create_bridge(pipeline, :pending)
+ create_bridge(pipeline, :running)
+ end
+
+ context 'with one scope element' do
+ let(:query) { { 'scope' => 'pending' } }
+
+ it :skip_before_request do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq 1
+ expect(json_response.first["status"]).to eq "pending"
+ end
+ end
+
+ context 'with array of scope elements' do
+ let(:query) { { scope: %w(pending running) } }
+
+ it :skip_before_request do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq 2
+ json_response.each { |r| expect(%w(pending running).include?(r['status'])).to be true }
+ end
+ end
+ end
+
+ context 'respond 400 when scope contains invalid state' do
+ context 'in an array' do
+ let(:query) { { scope: %w(unknown running) } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+
+ context 'in a hash' do
+ let(:query) { { scope: { unknown: true } } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+
+ context 'in a string' do
+ let(:query) { { scope: "unknown" } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+ end
+
+ context 'bridges in different pipelines' do
+ let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
+ let!(:bridge2) { create(:ci_bridge, pipeline: pipeline2) }
+
+ it 'excludes bridges from other pipelines' do
+ json_response.each { |bridge| expect(bridge['pipeline']['id']).to eq(pipeline.id) }
+ end
+ end
+
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+ end.count
+
+ 3.times { create_bridge(pipeline) }
+
+ expect do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+ end.not_to exceed_all_query_limit(control_count)
+ end
+ end
+
+ context 'no pipeline is found' do
+ it 'does not return bridges' do
+ get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/bridges", user)
+
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'unauthorized user' do
+ context 'when user is not logged in' do
+ let(:api_user) { nil }
+
+ it 'does not return bridges' do
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user is guest' do
+ let(:api_user) { guest }
+ let(:guest) { create(:project_member, :guest, project: project).user }
+
+ it 'does not return bridges' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when user has no read_build access for project' do
+ before do
+ project.add_guest(api_user)
+ end
+
+ it 'does not return bridges' do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user)
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ context 'with ci_jobs_finder_refactor ff disabled' do
+ before do
+ stub_feature_flags(ci_jobs_finder_refactor: false)
+ end
+
+ context 'authorized user' do
+ it 'returns pipeline bridges' 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
+ 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
+ expect(json_response.first['name']).to eq bridge.name
+ expect(json_response.first['stage']).to eq bridge.stage
+ end
+
+ it 'returns pipeline data' do
+ json_bridge = json_response.first
+
+ expect(json_bridge['pipeline']).not_to be_empty
+ expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id
+ expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref
+ expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha
+ expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status
+ end
+
+ it 'returns downstream pipeline data' do
+ json_bridge = json_response.first
+
+ expect(json_bridge['downstream_pipeline']).not_to be_empty
+ expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id
+ expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref
+ expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha
+ expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status
+ end
+
+ context 'filter bridges' do
+ before_all do
+ create_bridge(pipeline, :pending)
+ create_bridge(pipeline, :running)
+ end
+
+ context 'with one scope element' do
+ let(:query) { { 'scope' => 'pending' } }
+
+ it :skip_before_request do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq 1
+ expect(json_response.first["status"]).to eq "pending"
+ end
+ end
+
+ context 'with array of scope elements' do
+ let(:query) { { scope: %w(pending running) } }
+
+ it :skip_before_request do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq 2
+ json_response.each { |r| expect(%w(pending running).include?(r['status'])).to be true }
+ end
+ end
+ end
+
+ context 'respond 400 when scope contains invalid state' do
+ context 'in an array' do
+ let(:query) { { scope: %w(unknown running) } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+
+ context 'in a hash' do
+ let(:query) { { scope: { unknown: true } } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+
+ context 'in a string' do
+ let(:query) { { scope: "unknown" } }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+ end
+
+ context 'bridges in different pipelines' do
+ let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
+ let!(:bridge2) { create(:ci_bridge, pipeline: pipeline2) }
+
+ it 'excludes bridges from other pipelines' do
+ json_response.each { |bridge| expect(bridge['pipeline']['id']).to eq(pipeline.id) }
+ end
+ end
+
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+ end.count
+
+ 3.times { create_bridge(pipeline) }
+
+ expect do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+ end.not_to exceed_all_query_limit(control_count)
+ end
+ end
+
+ context 'no pipeline is found' do
+ it 'does not return bridges' do
+ get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/bridges", user)
+
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'unauthorized user' do
+ context 'when user is not logged in' do
+ let(:api_user) { nil }
+
+ it 'does not return bridges' do
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user is guest' do
+ let(:api_user) { guest }
+ let(:guest) { create(:project_member, :guest, project: project).user }
+
+ it 'does not return bridges' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when user has no read_build access for project' do
+ before do
+ project.add_guest(api_user)
+ end
+
+ it 'does not return bridges' do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user)
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ def create_bridge(pipeline, status = :created)
+ create(:ci_bridge, status: status, pipeline: pipeline).tap do |bridge|
+ downstream_pipeline = create(:ci_pipeline)
+ create(:ci_sources_pipeline,
+ source_pipeline: pipeline,
+ source_project: pipeline.project,
+ source_job: bridge,
+ pipeline: downstream_pipeline,
+ project: downstream_pipeline.project)
+ end
+ end
+ end
+
describe 'POST /projects/:id/pipeline ' do
def expect_variables(variables, expected_variables)
variables.each_with_index do |variable, index|
@@ -476,17 +1077,18 @@ RSpec.describe API::Ci::Pipelines do
end
end
- context 'when config source is not ci' do
- let(:non_ci_config_source) { ::Ci::PipelineEnums.non_ci_config_source_values.first }
- let(:pipeline_not_ci) do
- create(:ci_pipeline, config_source: non_ci_config_source, project: project)
+ context 'when pipeline is a dangling pipeline' do
+ let(:dangling_source) { Enums::Ci::Pipeline.dangling_sources.each_value.first }
+
+ let(:dangling_pipeline) do
+ create(:ci_pipeline, source: dangling_source, project: project)
end
it 'returns the specified pipeline' do
- get api("/projects/#{project.id}/pipelines/#{pipeline_not_ci.id}", user)
+ get api("/projects/#{project.id}/pipelines/#{dangling_pipeline.id}", user)
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['sha']).to eq(pipeline_not_ci.sha)
+ expect(json_response['sha']).to eq(dangling_pipeline.sha)
end
end
end
@@ -624,7 +1226,7 @@ RSpec.describe API::Ci::Pipelines do
end
it 'does not log an audit event' do
- expect { delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", owner) }.not_to change { SecurityEvent.count }
+ expect { delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", owner) }.not_to change { AuditEvent.count }
end
context 'when the pipeline has jobs' do
diff --git a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
index e5c60bb539b..97110b63ff6 100644
--- a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
@@ -143,6 +143,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
expect(json_response['TempPath']).to eq(JobArtifactUploader.workhorse_local_upload_path)
expect(json_response['RemoteObject']).to be_nil
+ expect(json_response['MaximumSize']).not_to be_nil
end
end
@@ -167,6 +168,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
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['MaximumSize']).not_to be_nil
end
end
@@ -188,6 +190,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
expect(json_response['TempPath']).not_to be_nil
+ expect(json_response['MaximumSize']).not_to be_nil
end
it 'fails to post too large artifact' do
@@ -235,36 +238,41 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
expect(json_response['ProcessLsif']).to be_truthy
end
- it 'adds ProcessLsifReferences header' do
- authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
+ it 'tracks code_intelligence usage ping' do
+ tracking_params = {
+ event_names: 'i_source_code_code_intelligence',
+ start_date: Date.yesterday,
+ end_date: Date.today
+ }
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['ProcessLsifReferences']).to be_truthy
+ expect { authorize_artifacts_with_token_in_headers(artifact_type: :lsif) }
+ .to change { Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(tracking_params) }
+ .by(1)
end
context 'code_navigation feature flag is disabled' do
- it 'responds with a forbidden error' do
+ before do
stub_feature_flags(code_navigation: false)
+ end
+
+ it 'responds with a forbidden error' do
authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
aggregate_failures do
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['ProcessLsif']).to be_falsy
- expect(json_response['ProcessLsifReferences']).to be_falsy
end
end
- end
- context 'code_navigation_references feature flag is disabled' do
- it 'sets ProcessLsifReferences header to false' do
- stub_feature_flags(code_navigation_references: false)
- authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
+ it 'does not track code_intelligence usage ping' do
+ tracking_params = {
+ event_names: 'i_source_code_code_intelligence',
+ start_date: Date.yesterday,
+ end_date: Date.today
+ }
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['ProcessLsif']).to be_truthy
- expect(json_response['ProcessLsifReferences']).to be_falsy
- end
+ expect { authorize_artifacts_with_token_in_headers(artifact_type: :lsif) }
+ .not_to change { Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(tracking_params) }
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 025747f2f0c..183a3b26e00 100644
--- a/spec/requests/api/ci/runner/jobs_put_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_put_spec.rb
@@ -105,6 +105,67 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
it { expect(job).to be_unmet_prerequisites }
end
+
+ context 'when unmigrated live trace chunks exist' do
+ context 'when accepting trace feature is enabled' do
+ before do
+ stub_feature_flags(ci_accept_trace: true)
+ end
+
+ context 'when checksum is present' do
+ context 'when live trace chunk is still live' do
+ it 'responds with 202' do
+ update_job(state: 'success', checksum: 'crc32:12345678')
+
+ expect(job.pending_state).to be_present
+ expect(response).to have_gitlab_http_status(:accepted)
+ end
+ end
+
+ context 'when runner retries request after receiving 202' do
+ it 'responds with 202 and then with 200', :sidekiq_inline do
+ perform_enqueued_jobs do
+ update_job(state: 'success', checksum: 'crc32:12345678')
+ end
+
+ expect(job.reload.pending_state).to be_present
+ expect(response).to have_gitlab_http_status(:accepted)
+
+ perform_enqueued_jobs do
+ update_job(state: 'success', checksum: 'crc32:12345678')
+ end
+
+ expect(job.reload.pending_state).not_to be_present
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when live trace chunk has been migrated' do
+ before do
+ job.trace_chunks.first.update!(data_store: :database)
+ end
+
+ it 'responds with 200' do
+ update_job(state: 'success', checksum: 'crc:12345678')
+
+ expect(job.reload).to be_success
+ expect(job.pending_state).not_to be_present
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'when checksum is not present' do
+ it 'responds with 200' do
+ update_job(state: 'success')
+
+ expect(job.reload).to be_success
+ expect(job.pending_state).not_to be_present
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
end
context 'when trace is given' do
diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb
index 21ff0a94db9..d34244771ad 100644
--- a/spec/requests/api/commits_spec.rb
+++ b/spec/requests/api/commits_spec.rb
@@ -367,10 +367,31 @@ RSpec.describe API::Commits do
end
end
- it 'does not increment the usage counters using access token authentication' do
- expect(::Gitlab::UsageDataCounters::WebIdeCounter).not_to receive(:increment_commits_count)
+ context 'when using access token authentication' do
+ it 'does not increment the usage counters' do
+ expect(::Gitlab::UsageDataCounters::WebIdeCounter).not_to receive(:increment_commits_count)
+ expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).not_to receive(:track_web_ide_edit_action)
- post api(url, user), params: valid_c_params
+ post api(url, user), params: valid_c_params
+ end
+ end
+
+ context 'when using warden' do
+ it 'increments usage counters', :clean_gitlab_redis_shared_state do
+ session_id = Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d')
+ session_hash = { 'warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]] }
+
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
+ end
+
+ cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
+
+ expect(::Gitlab::UsageDataCounters::WebIdeCounter).to receive(:increment_commits_count)
+ expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).to receive(:track_web_ide_edit_action)
+
+ post api(url), params: valid_c_params
+ end
end
context 'a new file in project repo' do
diff --git a/spec/requests/api/composer_packages_spec.rb b/spec/requests/api/composer_packages_spec.rb
index f5b8ebb545b..f5279af0483 100644
--- a/spec/requests/api/composer_packages_spec.rb
+++ b/spec/requests/api/composer_packages_spec.rb
@@ -26,30 +26,61 @@ RSpec.describe API::ComposerPackages do
group.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
end
- where(:project_visibility_level, :user_role, :member, :user_token, :include_package) do
- 'PUBLIC' | :developer | true | true | :include_package
- 'PUBLIC' | :developer | true | false | :include_package
- 'PUBLIC' | :developer | false | false | :include_package
- 'PUBLIC' | :developer | false | true | :include_package
- 'PUBLIC' | :guest | true | true | :include_package
- 'PUBLIC' | :guest | true | false | :include_package
- 'PUBLIC' | :guest | false | true | :include_package
- 'PUBLIC' | :guest | false | false | :include_package
- 'PUBLIC' | :anonymous | false | true | :include_package
- 'PRIVATE' | :developer | true | true | :include_package
- 'PRIVATE' | :developer | true | false | :does_not_include_package
- 'PRIVATE' | :developer | false | true | :does_not_include_package
- 'PRIVATE' | :developer | false | false | :does_not_include_package
- 'PRIVATE' | :guest | true | true | :does_not_include_package
- 'PRIVATE' | :guest | true | false | :does_not_include_package
- 'PRIVATE' | :guest | false | true | :does_not_include_package
- 'PRIVATE' | :guest | false | false | :does_not_include_package
- 'PRIVATE' | :anonymous | false | true | :does_not_include_package
+ context 'with basic auth' do
+ where(:project_visibility_level, :user_role, :member, :user_token, :include_package) do
+ 'PUBLIC' | :developer | true | true | :include_package
+ 'PUBLIC' | :developer | false | true | :include_package
+ 'PUBLIC' | :guest | true | true | :include_package
+ 'PUBLIC' | :guest | false | true | :include_package
+ 'PUBLIC' | :anonymous | false | true | :include_package
+ 'PRIVATE' | :developer | true | true | :include_package
+ 'PRIVATE' | :developer | false | true | :does_not_include_package
+ 'PRIVATE' | :guest | true | true | :does_not_include_package
+ 'PRIVATE' | :guest | false | true | :does_not_include_package
+ 'PRIVATE' | :anonymous | false | true | :does_not_include_package
+ 'PRIVATE' | :guest | false | false | :does_not_include_package
+ 'PRIVATE' | :guest | true | false | :does_not_include_package
+ 'PRIVATE' | :developer | false | false | :does_not_include_package
+ 'PRIVATE' | :developer | true | false | :does_not_include_package
+ 'PUBLIC' | :developer | true | false | :include_package
+ 'PUBLIC' | :guest | true | false | :include_package
+ 'PUBLIC' | :developer | false | false | :include_package
+ 'PUBLIC' | :guest | false | false | :include_package
+ end
+
+ with_them do
+ include_context 'Composer api project access', params[:project_visibility_level], params[:user_role], params[:user_token], :basic do
+ it_behaves_like 'Composer package index', params[:user_role], :success, params[:member], params[:include_package]
+ end
+ end
end
- with_them do
- include_context 'Composer api project access', params[:project_visibility_level], params[:user_role], params[:user_token] do
- it_behaves_like 'Composer package index', params[:user_role], :success, params[:member], params[:include_package]
+ context 'with private token header auth' do
+ where(:project_visibility_level, :user_role, :member, :user_token, :expected_status, :include_package) do
+ 'PUBLIC' | :developer | true | true | :success | :include_package
+ 'PUBLIC' | :developer | false | true | :success | :include_package
+ 'PUBLIC' | :guest | true | true | :success | :include_package
+ 'PUBLIC' | :guest | false | true | :success | :include_package
+ 'PUBLIC' | :anonymous | false | true | :success | :include_package
+ 'PRIVATE' | :developer | true | true | :success | :include_package
+ 'PRIVATE' | :developer | false | true | :success | :does_not_include_package
+ 'PRIVATE' | :guest | true | true | :success | :does_not_include_package
+ 'PRIVATE' | :guest | false | true | :success | :does_not_include_package
+ 'PRIVATE' | :anonymous | false | true | :success | :does_not_include_package
+ 'PRIVATE' | :guest | false | false | :unauthorized | nil
+ 'PRIVATE' | :guest | true | false | :unauthorized | nil
+ 'PRIVATE' | :developer | false | false | :unauthorized | nil
+ 'PRIVATE' | :developer | true | false | :unauthorized | nil
+ 'PUBLIC' | :developer | true | false | :unauthorized | nil
+ 'PUBLIC' | :guest | true | false | :unauthorized | nil
+ 'PUBLIC' | :developer | false | false | :unauthorized | nil
+ 'PUBLIC' | :guest | false | false | :unauthorized | nil
+ end
+
+ with_them do
+ include_context 'Composer api project access', params[:project_visibility_level], params[:user_role], params[:user_token], :token do
+ it_behaves_like 'Composer package index', params[:user_role], params[:expected_status], params[:member], params[:include_package]
+ end
end
end
end
@@ -105,22 +136,22 @@ RSpec.describe API::ComposerPackages do
context 'with valid project' do
where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do
'PUBLIC' | :developer | true | true | 'Composer provider index' | :success
- 'PUBLIC' | :developer | true | false | 'Composer provider index' | :success
+ 'PUBLIC' | :developer | true | false | 'process Composer api request' | :unauthorized
'PUBLIC' | :developer | false | true | 'Composer provider index' | :success
- 'PUBLIC' | :developer | false | false | 'Composer provider index' | :success
+ 'PUBLIC' | :developer | false | false | 'process Composer api request' | :unauthorized
'PUBLIC' | :guest | true | true | 'Composer provider index' | :success
- 'PUBLIC' | :guest | true | false | 'Composer provider index' | :success
+ 'PUBLIC' | :guest | true | false | 'process Composer api request' | :unauthorized
'PUBLIC' | :guest | false | true | 'Composer provider index' | :success
- 'PUBLIC' | :guest | false | false | 'Composer provider index' | :success
+ 'PUBLIC' | :guest | false | false | 'process Composer api request' | :unauthorized
'PUBLIC' | :anonymous | false | true | 'Composer provider index' | :success
'PRIVATE' | :developer | true | true | 'Composer provider index' | :success
- 'PRIVATE' | :developer | true | false | 'process Composer api request' | :not_found
+ 'PRIVATE' | :developer | true | false | 'process Composer api request' | :unauthorized
'PRIVATE' | :developer | false | true | 'process Composer api request' | :not_found
- 'PRIVATE' | :developer | false | false | 'process Composer api request' | :not_found
+ 'PRIVATE' | :developer | false | false | 'process Composer api request' | :unauthorized
'PRIVATE' | :guest | true | true | 'Composer empty provider index' | :success
- 'PRIVATE' | :guest | true | false | 'process Composer api request' | :not_found
+ 'PRIVATE' | :guest | true | false | 'process Composer api request' | :unauthorized
'PRIVATE' | :guest | false | true | 'process Composer api request' | :not_found
- 'PRIVATE' | :guest | false | false | 'process Composer api request' | :not_found
+ 'PRIVATE' | :guest | false | false | 'process Composer api request' | :unauthorized
'PRIVATE' | :anonymous | false | true | 'process Composer api request' | :not_found
end
@@ -151,22 +182,22 @@ RSpec.describe API::ComposerPackages do
where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do
'PUBLIC' | :developer | true | true | 'Composer package api request' | :success
- 'PUBLIC' | :developer | true | false | 'Composer package api request' | :success
+ 'PUBLIC' | :developer | true | false | 'process Composer api request' | :unauthorized
'PUBLIC' | :developer | false | true | 'Composer package api request' | :success
- 'PUBLIC' | :developer | false | false | 'Composer package api request' | :success
+ 'PUBLIC' | :developer | false | false | 'process Composer api request' | :unauthorized
'PUBLIC' | :guest | true | true | 'Composer package api request' | :success
- 'PUBLIC' | :guest | true | false | 'Composer package api request' | :success
+ 'PUBLIC' | :guest | true | false | 'process Composer api request' | :unauthorized
'PUBLIC' | :guest | false | true | 'Composer package api request' | :success
- 'PUBLIC' | :guest | false | false | 'Composer package api request' | :success
+ 'PUBLIC' | :guest | false | false | 'process Composer api request' | :unauthorized
'PUBLIC' | :anonymous | false | true | 'Composer package api request' | :success
'PRIVATE' | :developer | true | true | 'Composer package api request' | :success
- 'PRIVATE' | :developer | true | false | 'process Composer api request' | :not_found
+ 'PRIVATE' | :developer | true | false | 'process Composer api request' | :unauthorized
'PRIVATE' | :developer | false | true | 'process Composer api request' | :not_found
- 'PRIVATE' | :developer | false | false | 'process Composer api request' | :not_found
+ 'PRIVATE' | :developer | false | false | 'process Composer api request' | :unauthorized
'PRIVATE' | :guest | true | true | 'process Composer api request' | :not_found
- 'PRIVATE' | :guest | true | false | 'process Composer api request' | :not_found
+ 'PRIVATE' | :guest | true | false | 'process Composer api request' | :unauthorized
'PRIVATE' | :guest | false | true | 'process Composer api request' | :not_found
- 'PRIVATE' | :guest | false | false | 'process Composer api request' | :not_found
+ 'PRIVATE' | :guest | false | false | 'process Composer api request' | :unauthorized
'PRIVATE' | :anonymous | false | true | 'process Composer api request' | :not_found
end
diff --git a/spec/requests/api/conan_instance_packages_spec.rb b/spec/requests/api/conan_instance_packages_spec.rb
new file mode 100644
index 00000000000..817530f0bad
--- /dev/null
+++ b/spec/requests/api/conan_instance_packages_spec.rb
@@ -0,0 +1,152 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::ConanInstancePackages do
+ include_context 'conan api setup'
+
+ describe 'GET /api/v4/packages/conan/v1/ping' do
+ let_it_be(:url) { '/packages/conan/v1/ping' }
+
+ it_behaves_like 'conan ping endpoint'
+ end
+
+ describe 'GET /api/v4/packages/conan/v1/conans/search' do
+ let_it_be(:url) { '/packages/conan/v1/conans/search' }
+
+ it_behaves_like 'conan search endpoint'
+ end
+
+ describe 'GET /api/v4/packages/conan/v1/users/authenticate' do
+ let_it_be(:url) { '/packages/conan/v1/users/authenticate' }
+
+ it_behaves_like 'conan authenticate endpoint'
+ end
+
+ describe 'GET /api/v4/packages/conan/v1/users/check_credentials' do
+ let_it_be(:url) { "/packages/conan/v1/users/check_credentials" }
+
+ it_behaves_like 'conan check_credentials endpoint'
+ end
+
+ context 'recipe endpoints' do
+ include_context 'conan recipe endpoints'
+
+ let(:project_id) { 9999 }
+ let(:url_prefix) { "#{Settings.gitlab.base_url}/api/v4" }
+
+ describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
+ let(:recipe_path) { package.conan_recipe_path }
+ let(:url) { "/packages/conan/v1/conans/#{recipe_path}" }
+
+ it_behaves_like 'recipe snapshot endpoint'
+ end
+
+ describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference' do
+ let(:recipe_path) { package.conan_recipe_path }
+ let(:url) { "/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}" }
+
+ it_behaves_like 'package snapshot endpoint'
+ end
+
+ describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/digest' do
+ subject { get api("/packages/conan/v1/conans/#{recipe_path}/digest"), headers: headers }
+
+ it_behaves_like 'recipe download_urls endpoint'
+ end
+
+ describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/download_urls' do
+ subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/download_urls"), headers: headers }
+
+ it_behaves_like 'package download_urls endpoint'
+ end
+
+ describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/download_urls' do
+ subject { get api("/packages/conan/v1/conans/#{recipe_path}/download_urls"), headers: headers }
+
+ it_behaves_like 'recipe download_urls endpoint'
+ end
+
+ describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/digest' do
+ subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/digest"), headers: headers }
+
+ it_behaves_like 'package download_urls endpoint'
+ end
+
+ describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/upload_urls' do
+ subject { post api("/packages/conan/v1/conans/#{recipe_path}/upload_urls"), params: params.to_json, headers: headers }
+
+ it_behaves_like 'recipe upload_urls endpoint'
+ end
+
+ describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/upload_urls' do
+ subject { post api("/packages/conan/v1/conans/#{recipe_path}/packages/123456789/upload_urls"), params: params.to_json, headers: headers }
+
+ it_behaves_like 'package upload_urls endpoint'
+ end
+
+ describe 'DELETE /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
+ subject { delete api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers}
+
+ it_behaves_like 'delete package endpoint'
+ end
+ end
+
+ context 'file download endpoints' do
+ include_context 'conan file download endpoints'
+
+ describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
+:recipe_revision/export/:file_name' do
+ subject do
+ get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/export/#{recipe_file.file_name}"),
+ headers: headers
+ end
+
+ it_behaves_like 'recipe file download endpoint'
+ it_behaves_like 'project not found by recipe'
+ end
+
+ describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
+:recipe_revision/package/:conan_package_reference/:package_revision/:file_name' do
+ subject do
+ get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/package/#{metadata.conan_package_reference}/#{metadata.package_revision}/#{package_file.file_name}"),
+ headers: headers
+ end
+
+ it_behaves_like 'package file download endpoint'
+ it_behaves_like 'project not found by recipe'
+ end
+ end
+
+ context 'file upload endpoints' do
+ include_context 'conan file upload endpoints'
+
+ describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name/authorize' do
+ let(:file_name) { 'conanfile.py' }
+
+ subject { put api("/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}/authorize"), headers: headers_with_token }
+
+ it_behaves_like 'workhorse authorize endpoint'
+ end
+
+ describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name/authorize' do
+ let(:file_name) { 'conaninfo.txt' }
+
+ subject { put api("/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}/authorize"), headers: headers_with_token }
+
+ it_behaves_like 'workhorse authorize endpoint'
+ end
+
+ describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name' do
+ let(:url) { "/api/v4/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}" }
+
+ it_behaves_like 'workhorse recipe file upload endpoint'
+ end
+
+ describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name' do
+ let(:url) { "/api/v4/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}" }
+
+ it_behaves_like 'workhorse package file upload endpoint'
+ end
+ end
+end
diff --git a/spec/requests/api/conan_packages_spec.rb b/spec/requests/api/conan_packages_spec.rb
deleted file mode 100644
index d24f6b68048..00000000000
--- a/spec/requests/api/conan_packages_spec.rb
+++ /dev/null
@@ -1,954 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe API::ConanPackages do
- include WorkhorseHelpers
- include HttpBasicAuthHelpers
- include PackagesManagerApiSpecHelpers
-
- let(:package) { create(:conan_package) }
- let_it_be(:personal_access_token) { create(:personal_access_token) }
- let_it_be(:user) { personal_access_token.user }
- let(:project) { package.project }
-
- let(:base_secret) { SecureRandom.base64(64) }
- let(:auth_token) { personal_access_token.token }
- let(:job) { create(:ci_build, user: user, status: :running) }
- let(:job_token) { job.token }
- let(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
- let(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
-
- let(:headers) do
- { 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('foo', auth_token) }
- end
-
- let(:jwt_secret) do
- OpenSSL::HMAC.hexdigest(
- OpenSSL::Digest::SHA256.new,
- base_secret,
- Gitlab::ConanToken::HMAC_KEY
- )
- end
-
- before do
- project.add_developer(user)
- allow(Settings).to receive(:attr_encrypted_db_key_base).and_return(base_secret)
- end
-
- describe 'GET /api/v4/packages/conan/v1/ping' do
- it 'responds with 401 Unauthorized when no token provided' do
- get api('/packages/conan/v1/ping')
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'responds with 200 OK when valid token is provided' do
- jwt = build_jwt(personal_access_token)
- get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
- end
-
- it 'responds with 200 OK when valid job token is provided' do
- jwt = build_jwt_from_job(job)
- get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
- end
-
- it 'responds with 200 OK when valid deploy token is provided' do
- jwt = build_jwt_from_deploy_token(deploy_token)
- get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
- end
-
- it 'responds with 401 Unauthorized when invalid access token ID is provided' do
- jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id)
- get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'responds with 401 Unauthorized when invalid user is provided' do
- jwt = build_jwt(personal_access_token, user_id: 12345)
- get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do
- jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32))
- get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'responds with 401 Unauthorized when invalid JWT is provided' do
- get api('/packages/conan/v1/ping'), headers: build_token_auth_header('invalid-jwt')
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'responds with 401 Unauthorized when the job is not running' do
- job.update!(status: :failed)
- jwt = build_jwt_from_job(job)
- get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- context 'packages feature disabled' do
- it 'responds with 404 Not Found' do
- stub_packages_setting(enabled: false)
- get api('/packages/conan/v1/ping')
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
-
- describe 'GET /api/v4/packages/conan/v1/conans/search' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
-
- get api('/packages/conan/v1/conans/search'), headers: headers, params: params
- end
-
- subject { json_response['results'] }
-
- context 'returns packages with a matching name' do
- let(:params) { { q: package.conan_recipe } }
-
- it { is_expected.to contain_exactly(package.conan_recipe) }
- end
-
- context 'returns packages using a * wildcard' do
- let(:params) { { q: "#{package.name[0, 3]}*" } }
-
- it { is_expected.to contain_exactly(package.conan_recipe) }
- end
-
- context 'does not return non-matching packages' do
- let(:params) { { q: "foo" } }
-
- it { is_expected.to be_blank }
- end
- end
-
- describe 'GET /api/v4/packages/conan/v1/users/authenticate' do
- subject { get api('/packages/conan/v1/users/authenticate'), headers: headers }
-
- context 'when using invalid token' do
- let(:auth_token) { 'invalid_token' }
-
- it 'responds with 401' do
- subject
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
-
- context 'when valid JWT access token is provided' do
- it 'responds with 200' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- it 'token has valid validity time' do
- Timecop.freeze do
- subject
-
- payload = JSONWebToken::HMACToken.decode(
- response.body, jwt_secret).first
- expect(payload['access_token']).to eq(personal_access_token.id)
- expect(payload['user_id']).to eq(personal_access_token.user_id)
-
- duration = payload['exp'] - payload['iat']
- expect(duration).to eq(1.hour)
- end
- end
- end
-
- context 'with valid job token' do
- let(:auth_token) { job_token }
-
- it 'responds with 200' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
-
- context 'with valid deploy token' do
- let(:auth_token) { deploy_token.token }
-
- it 'responds with 200' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
-
- describe 'GET /api/v4/packages/conan/v1/users/check_credentials' do
- it 'responds with a 200 OK with PAT' do
- get api('/packages/conan/v1/users/check_credentials'), headers: headers
-
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- context 'with job token' do
- let(:auth_token) { job_token }
-
- it 'responds with a 200 OK with job token' do
- get api('/packages/conan/v1/users/check_credentials'), headers: headers
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
-
- context 'with deploy token' do
- let(:auth_token) { deploy_token.token }
-
- it 'responds with a 200 OK with job token' do
- get api('/packages/conan/v1/users/check_credentials'), headers: headers
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
-
- it 'responds with a 401 Unauthorized when an invalid token is used' do
- get api('/packages/conan/v1/users/check_credentials'), headers: build_token_auth_header('invalid-token')
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
-
- shared_examples 'rejects invalid recipe' do
- context 'with invalid recipe path' do
- let(:recipe_path) { '../../foo++../..' }
-
- it 'returns 400' do
- subject
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
- end
-
- shared_examples 'rejects invalid file_name' do |invalid_file_name|
- let(:file_name) { invalid_file_name }
-
- context 'with invalid file_name' do
- it 'returns 400' do
- subject
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
- end
-
- shared_examples 'rejects recipe for invalid project' do
- context 'with invalid recipe path' do
- let(:recipe_path) { 'aa/bb/not-existing-project/ccc' }
-
- it 'returns forbidden' do
- subject
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
- end
-
- shared_examples 'rejects recipe for not found package' do
- context 'with invalid recipe path' do
- let(:recipe_path) do
- 'aa/bb/%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }
- end
-
- it 'returns not found' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
-
- shared_examples 'empty recipe for not found package' do
- context 'with invalid recipe url' do
- let(:recipe_path) do
- 'aa/bb/%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }
- end
-
- it 'returns not found' do
- allow(::Packages::Conan::PackagePresenter).to receive(:new)
- .with(
- 'aa/bb@%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) },
- user,
- project,
- any_args
- ).and_return(presenter)
- allow(presenter).to receive(:recipe_snapshot) { {} }
- allow(presenter).to receive(:package_snapshot) { {} }
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to eq("{}")
- end
- end
- end
-
- shared_examples 'recipe download_urls' do
- let(:recipe_path) { package.conan_recipe_path }
-
- it 'returns the download_urls for the recipe files' do
- expected_response = {
- 'conanfile.py' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
- 'conanmanifest.txt' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
- }
-
- allow(presenter).to receive(:recipe_urls) { expected_response }
-
- subject
-
- expect(json_response).to eq(expected_response)
- end
- end
-
- shared_examples 'package download_urls' do
- let(:recipe_path) { package.conan_recipe_path }
-
- it 'returns the download_urls for the package files' do
- expected_response = {
- 'conaninfo.txt' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt",
- 'conanmanifest.txt' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt",
- 'conan_package.tgz' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz"
- }
-
- allow(presenter).to receive(:package_urls) { expected_response }
-
- subject
-
- expect(json_response).to eq(expected_response)
- end
- end
-
- context 'recipe endpoints' do
- let(:jwt) { build_jwt(personal_access_token) }
- let(:headers) { build_token_auth_header(jwt.encoded) }
- let(:conan_package_reference) { '123456789' }
- let(:presenter) { double('::Packages::Conan::PackagePresenter') }
-
- before do
- allow(::Packages::Conan::PackagePresenter).to receive(:new)
- .with(package.conan_recipe, user, package.project, any_args)
- .and_return(presenter)
- end
-
- shared_examples 'rejects invalid upload_url params' do
- context 'with unaccepted json format' do
- let(:params) { %w[foo bar] }
-
- it 'returns 400' do
- subject
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
- end
-
- shared_examples 'successful response when using Unicorn' do
- context 'on Unicorn', :unicorn do
- it 'returns successfully' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
-
- describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
- let(:recipe_path) { package.conan_recipe_path }
-
- subject { get api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects recipe for invalid project'
- it_behaves_like 'empty recipe for not found package'
-
- context 'with existing package' do
- it 'returns a hash of files with their md5 hashes' do
- expected_response = {
- 'conanfile.py' => 'md5hash1',
- 'conanmanifest.txt' => 'md5hash2'
- }
-
- allow(presenter).to receive(:recipe_snapshot) { expected_response }
-
- subject
-
- expect(json_response).to eq(expected_response)
- end
- end
- end
-
- describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference' do
- let(:recipe_path) { package.conan_recipe_path }
-
- subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}"), headers: headers }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects recipe for invalid project'
- it_behaves_like 'empty recipe for not found package'
-
- context 'with existing package' do
- it 'returns a hash of md5 values for the files' do
- expected_response = {
- 'conaninfo.txt' => "md5hash1",
- 'conanmanifest.txt' => "md5hash2",
- 'conan_package.tgz' => "md5hash3"
- }
-
- allow(presenter).to receive(:package_snapshot) { expected_response }
-
- subject
-
- expect(json_response).to eq(expected_response)
- end
- end
- end
-
- describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/digest' do
- subject { get api("/packages/conan/v1/conans/#{recipe_path}/digest"), headers: headers }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects recipe for invalid project'
- it_behaves_like 'recipe download_urls'
- end
-
- describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/download_urls' do
- subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/download_urls"), headers: headers }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects recipe for invalid project'
- it_behaves_like 'package download_urls'
- end
-
- describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/download_urls' do
- subject { get api("/packages/conan/v1/conans/#{recipe_path}/download_urls"), headers: headers }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects recipe for invalid project'
- it_behaves_like 'recipe download_urls'
- end
-
- describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/digest' do
- subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/digest"), headers: headers }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects recipe for invalid project'
- it_behaves_like 'package download_urls'
- end
-
- describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/upload_urls' do
- let(:recipe_path) { package.conan_recipe_path }
-
- let(:params) do
- { 'conanfile.py': 24,
- 'conanmanifest.txt': 123 }
- end
-
- subject { post api("/packages/conan/v1/conans/#{recipe_path}/upload_urls"), params: params.to_json, headers: headers }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects invalid upload_url params'
- it_behaves_like 'successful response when using Unicorn'
-
- it 'returns a set of upload urls for the files requested' do
- subject
-
- expected_response = {
- 'conanfile.py': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
- 'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
- }
-
- expect(response.body).to eq(expected_response.to_json)
- end
-
- context 'with conan_sources and conan_export files' do
- let(:params) do
- { 'conan_sources.tgz': 345,
- 'conan_export.tgz': 234,
- 'conanmanifest.txt': 123 }
- end
-
- it 'returns upload urls for the additional files' do
- subject
-
- expected_response = {
- 'conan_sources.tgz': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_sources.tgz",
- 'conan_export.tgz': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_export.tgz",
- 'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
- }
-
- expect(response.body).to eq(expected_response.to_json)
- end
- end
-
- context 'with an invalid file' do
- let(:params) do
- { 'invalid_file.txt': 10,
- 'conanmanifest.txt': 123 }
- end
-
- it 'does not return the invalid file as an upload_url' do
- subject
-
- expected_response = {
- 'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
- }
-
- expect(response.body).to eq(expected_response.to_json)
- end
- end
- end
-
- describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/upload_urls' do
- let(:recipe_path) { package.conan_recipe_path }
-
- let(:params) do
- { 'conaninfo.txt': 24,
- 'conanmanifest.txt': 123,
- 'conan_package.tgz': 523 }
- end
-
- subject { post api("/packages/conan/v1/conans/#{recipe_path}/packages/123456789/upload_urls"), params: params.to_json, headers: headers }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects invalid upload_url params'
- it_behaves_like 'successful response when using Unicorn'
-
- it 'returns a set of upload urls for the files requested' do
- expected_response = {
- 'conaninfo.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt",
- 'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt",
- 'conan_package.tgz': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz"
- }
-
- subject
-
- expect(response.body).to eq(expected_response.to_json)
- end
-
- context 'with invalid files' do
- let(:params) do
- { 'conaninfo.txt': 24,
- 'invalid_file.txt': 10 }
- end
-
- it 'returns upload urls only for the valid requested files' do
- expected_response = {
- 'conaninfo.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt"
- }
-
- subject
-
- expect(response.body).to eq(expected_response.to_json)
- end
- end
- end
-
- describe 'DELETE /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
- let(:recipe_path) { package.conan_recipe_path }
-
- subject { delete api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers}
-
- it_behaves_like 'rejects invalid recipe'
-
- it 'returns unauthorized for users without valid permission' do
- subject
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
-
- context 'with delete permissions' do
- before do
- project.add_maintainer(user)
- end
-
- it_behaves_like 'a gitlab tracking event', described_class.name, 'delete_package'
-
- it 'deletes a package' do
- expect { subject }.to change { Packages::Package.count }.from(2).to(1)
- end
- end
- end
- end
-
- context 'file endpoints' do
- let(:jwt) { build_jwt(personal_access_token) }
- let(:headers) { build_token_auth_header(jwt.encoded) }
- let(:recipe_path) { package.conan_recipe_path }
-
- shared_examples 'denies download with no token' do
- context 'with no private token' do
- let(:headers) { {} }
-
- it 'returns 400' do
- subject
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
- end
-
- shared_examples 'a public project with packages' do
- it 'returns the file' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
- end
- end
-
- shared_examples 'an internal project with packages' do
- before do
- project.team.truncate
- project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
- end
-
- it_behaves_like 'denies download with no token'
-
- it 'returns the file' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
- end
- end
-
- shared_examples 'a private project with packages' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- end
-
- it_behaves_like 'denies download with no token'
-
- it 'returns the file' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
- end
-
- it 'denies download when not enough permissions' do
- project.add_guest(user)
-
- subject
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- shared_examples 'a project is not found' do
- let(:recipe_path) { 'not/package/for/project' }
-
- it 'returns forbidden' do
- subject
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
-:recipe_revision/export/:file_name' do
- let(:recipe_file) { package.package_files.find_by(file_name: 'conanfile.py') }
- let(:metadata) { recipe_file.conan_file_metadatum }
-
- subject do
- get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/export/#{recipe_file.file_name}"),
- headers: headers
- end
-
- it_behaves_like 'a public project with packages'
- it_behaves_like 'an internal project with packages'
- it_behaves_like 'a private project with packages'
- it_behaves_like 'a project is not found'
- end
-
- describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
-:recipe_revision/package/:conan_package_reference/:package_revision/:file_name' do
- let(:package_file) { package.package_files.find_by(file_name: 'conaninfo.txt') }
- let(:metadata) { package_file.conan_file_metadatum }
-
- subject do
- get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/package/#{metadata.conan_package_reference}/#{metadata.package_revision}/#{package_file.file_name}"),
- headers: headers
- end
-
- it_behaves_like 'a public project with packages'
- it_behaves_like 'an internal project with packages'
- it_behaves_like 'a private project with packages'
- it_behaves_like 'a project is not found'
-
- context 'tracking the conan_package.tgz download' do
- let(:package_file) { package.package_files.find_by(file_name: ::Packages::Conan::FileMetadatum::PACKAGE_BINARY) }
-
- it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package'
- end
- end
- end
-
- context 'file uploads' do
- let(:jwt) { build_jwt(personal_access_token) }
- let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') }
- let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } }
- let(:headers_with_token) { build_token_auth_header(jwt.encoded).merge(workhorse_header) }
- let(:recipe_path) { "foo/bar/#{project.full_path.tr('/', '+')}/baz"}
-
- shared_examples 'uploads a package file' do
- context 'with object storage disabled' do
- context 'without a file from workhorse' do
- let(:params) { { file: nil } }
-
- it 'rejects the request' do
- subject
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
-
- context 'with a file' do
- it_behaves_like 'package workhorse uploads'
- end
-
- context 'without a token' do
- it 'rejects request without a token' do
- headers_with_token.delete('HTTP_AUTHORIZATION')
-
- subject
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
-
- context 'when params from workhorse are correct' do
- it 'creates package and stores package file' do
- expect { subject }
- .to change { project.packages.count }.by(1)
- .and change { Packages::PackageFile.count }.by(1)
-
- expect(response).to have_gitlab_http_status(:ok)
-
- package_file = project.packages.last.package_files.reload.last
- expect(package_file.file_name).to eq(params[:file].original_filename)
- end
-
- it "doesn't attempt to migrate file to object storage" do
- expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
-
- subject
- end
- end
- end
-
- context 'with object storage enabled' do
- context 'and direct upload enabled' do
- let!(:fog_connection) do
- stub_package_file_object_storage(direct_upload: true)
- end
-
- let(:tmp_object) do
- fog_connection.directories.new(key: 'packages').files.create(
- key: "tmp/uploads/#{file_name}",
- body: 'content'
- )
- end
-
- let(:fog_file) { fog_to_uploaded_file(tmp_object) }
-
- ['123123', '../../123123'].each do |remote_id|
- context "with invalid remote_id: #{remote_id}" do
- let(:params) do
- {
- file: fog_file,
- 'file.remote_id' => remote_id
- }
- end
-
- it 'responds with status 403' do
- subject
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
- end
-
- context 'with valid remote_id' do
- let(:params) do
- {
- file: fog_file,
- 'file.remote_id' => file_name
- }
- end
-
- it 'creates package and stores package file' do
- expect { subject }
- .to change { project.packages.count }.by(1)
- .and change { Packages::PackageFile.count }.by(1)
-
- expect(response).to have_gitlab_http_status(:ok)
-
- package_file = project.packages.last.package_files.reload.last
- expect(package_file.file_name).to eq(params[:file].original_filename)
- expect(package_file.file.read).to eq('content')
- end
- end
- end
-
- it_behaves_like 'background upload schedules a file migration'
- end
- end
-
- shared_examples 'workhorse authorization' do
- it 'authorizes posting package with a valid token' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
-
- it 'rejects request without a valid token' do
- headers_with_token['HTTP_AUTHORIZATION'] = 'foo'
-
- subject
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'rejects request without a valid permission' do
- project.add_guest(user)
-
- subject
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
-
- it 'rejects requests that bypassed gitlab-workhorse' do
- headers_with_token.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER)
-
- subject
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
-
- context 'when using remote storage' do
- context 'when direct upload is enabled' do
- before do
- stub_package_file_object_storage(enabled: true, direct_upload: true)
- end
-
- it 'responds with status 200, location of package remote store and object details' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- expect(json_response).not_to have_key('TempPath')
- expect(json_response['RemoteObject']).to have_key('ID')
- expect(json_response['RemoteObject']).to have_key('GetURL')
- expect(json_response['RemoteObject']).to have_key('StoreURL')
- expect(json_response['RemoteObject']).to have_key('DeleteURL')
- expect(json_response['RemoteObject']).not_to have_key('MultipartUpload')
- end
- end
-
- context 'when direct upload is disabled' do
- before do
- stub_package_file_object_storage(enabled: true, direct_upload: false)
- end
-
- it 'handles as a local file' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- expect(json_response['TempPath']).to eq(::Packages::PackageFileUploader.workhorse_local_upload_path)
- expect(json_response['RemoteObject']).to be_nil
- end
- end
- end
- end
-
- describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name/authorize' do
- let(:file_name) { 'conanfile.py' }
-
- subject { put api("/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}/authorize"), headers: headers_with_token }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack'
- it_behaves_like 'workhorse authorization'
- end
-
- describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name/authorize' do
- let(:file_name) { 'conaninfo.txt' }
-
- subject { put api("/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}/authorize"), headers: headers_with_token }
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects invalid file_name', 'conaninfo.txttest'
- it_behaves_like 'workhorse authorization'
- end
-
- describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name' do
- let(:file_name) { 'conanfile.py' }
- let(:params) { { file: temp_file(file_name) } }
-
- subject do
- workhorse_finalize(
- "/api/v4/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}",
- method: :put,
- file_key: :file,
- params: params,
- send_rewritten_field: true,
- headers: headers_with_token
- )
- end
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack'
- it_behaves_like 'uploads a package file'
- end
-
- describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name' do
- let(:file_name) { 'conaninfo.txt' }
- let(:params) { { file: temp_file(file_name) } }
-
- subject do
- workhorse_finalize(
- "/api/v4/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}",
- method: :put,
- file_key: :file,
- params: params,
- headers: headers_with_token,
- send_rewritten_field: true
- )
- end
-
- it_behaves_like 'rejects invalid recipe'
- it_behaves_like 'rejects invalid file_name', 'conaninfo.txttest'
- it_behaves_like 'uploads a package file'
-
- context 'tracking the conan_package.tgz upload' do
- let(:file_name) { ::Packages::Conan::FileMetadatum::PACKAGE_BINARY }
-
- it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
- end
- end
- end
-end
diff --git a/spec/requests/api/conan_project_packages_spec.rb b/spec/requests/api/conan_project_packages_spec.rb
new file mode 100644
index 00000000000..fefaf9790b1
--- /dev/null
+++ b/spec/requests/api/conan_project_packages_spec.rb
@@ -0,0 +1,152 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe API::ConanProjectPackages do
+ include_context 'conan api setup'
+
+ let(:project_id) { project.id }
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/ping' do
+ let(:url) { "/projects/#{project.id}/packages/conan/v1/ping" }
+
+ it_behaves_like 'conan ping endpoint'
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/search' do
+ let(:url) { "/projects/#{project.id}/packages/conan/v1/conans/search" }
+
+ it_behaves_like 'conan search endpoint'
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/users/authenticate' do
+ let(:url) { "/projects/#{project.id}/packages/conan/v1/users/authenticate" }
+
+ it_behaves_like 'conan authenticate endpoint'
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/users/check_credentials' do
+ let(:url) { "/projects/#{project.id}/packages/conan/v1/users/check_credentials" }
+
+ it_behaves_like 'conan check_credentials endpoint'
+ end
+
+ context 'recipe endpoints' do
+ include_context 'conan recipe endpoints'
+
+ let(:url_prefix) { "#{Settings.gitlab.base_url}/api/v4/projects/#{project_id}" }
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
+ let(:recipe_path) { package.conan_recipe_path }
+ let(:url) { "/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}" }
+
+ it_behaves_like 'recipe snapshot endpoint'
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference' do
+ let(:recipe_path) { package.conan_recipe_path }
+ let(:url) { "/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}" }
+
+ it_behaves_like 'package snapshot endpoint'
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/digest' do
+ subject { get api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/digest"), headers: headers }
+
+ it_behaves_like 'recipe download_urls endpoint'
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/download_urls' do
+ subject { get api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/download_urls"), headers: headers }
+
+ it_behaves_like 'package download_urls endpoint'
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/download_urls' do
+ subject { get api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/download_urls"), headers: headers }
+
+ it_behaves_like 'recipe download_urls endpoint'
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/digest' do
+ subject { get api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/digest"), headers: headers }
+
+ it_behaves_like 'package download_urls endpoint'
+ end
+
+ describe 'POST /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/upload_urls' do
+ subject { post api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/upload_urls"), params: params.to_json, headers: headers }
+
+ it_behaves_like 'recipe upload_urls endpoint'
+ end
+
+ describe 'POST /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/upload_urls' do
+ subject { post api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/packages/123456789/upload_urls"), params: params.to_json, headers: headers }
+
+ it_behaves_like 'package upload_urls endpoint'
+ end
+
+ describe 'DELETE /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
+ subject { delete api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}"), headers: headers}
+
+ it_behaves_like 'delete package endpoint'
+ end
+ end
+
+ context 'file download endpoints' do
+ include_context 'conan file download endpoints'
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
+:recipe_revision/export/:file_name' do
+ subject do
+ get api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/export/#{recipe_file.file_name}"),
+ headers: headers
+ end
+
+ it_behaves_like 'recipe file download endpoint'
+ it_behaves_like 'project not found by project id'
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
+:recipe_revision/package/:conan_package_reference/:package_revision/:file_name' do
+ subject do
+ get api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/package/#{metadata.conan_package_reference}/#{metadata.package_revision}/#{package_file.file_name}"),
+ headers: headers
+ end
+
+ it_behaves_like 'package file download endpoint'
+ it_behaves_like 'project not found by project id'
+ end
+ end
+
+ context 'file upload endpoints' do
+ include_context 'conan file upload endpoints'
+
+ describe 'PUT /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name/authorize' do
+ let(:file_name) { 'conanfile.py' }
+
+ subject { put api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}/authorize"), headers: headers_with_token }
+
+ it_behaves_like 'workhorse authorize endpoint'
+ end
+
+ describe 'PUT /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name/authorize' do
+ let(:file_name) { 'conaninfo.txt' }
+
+ subject { put api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}/authorize"), headers: headers_with_token }
+
+ it_behaves_like 'workhorse authorize endpoint'
+ end
+
+ describe 'PUT /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name' do
+ let(:url) { "/api/v4/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}" }
+
+ it_behaves_like 'workhorse recipe file upload endpoint'
+ end
+
+ describe 'PUT /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name' do
+ let(:url) { "/api/v4/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}" }
+
+ it_behaves_like 'workhorse package file upload endpoint'
+ end
+ end
+end
diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb
new file mode 100644
index 00000000000..ed852fe75c7
--- /dev/null
+++ b/spec/requests/api/generic_packages_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::GenericPackages do
+ let_it_be(:personal_access_token) { create(:personal_access_token) }
+ let_it_be(:project) { create(:project) }
+
+ describe 'GET /api/v4/projects/:id/packages/generic/ping' do
+ let(:user) { personal_access_token.user }
+ let(:auth_token) { personal_access_token.token }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'packages feature is disabled' do
+ it 'responds with 404 Not Found' do
+ stub_packages_setting(enabled: false)
+
+ ping(personal_access_token: auth_token)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'generic_packages feature flag is disabled' do
+ it 'responds with 404 Not Found' do
+ stub_feature_flags(generic_packages: false)
+
+ ping(personal_access_token: auth_token)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'generic_packages feature flag is enabled' do
+ before do
+ stub_feature_flags(generic_packages: true)
+ end
+
+ context 'authenticating using personal access token' do
+ it 'responds with 200 OK when valid personal access token is provided' do
+ ping(personal_access_token: auth_token)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'responds with 401 Unauthorized when invalid personal access token provided' do
+ ping(personal_access_token: 'invalid-token')
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'authenticating using job token' do
+ it 'responds with 200 OK when valid job token is provided' do
+ job_token = create(:ci_build, :running, user: user).token
+
+ ping(job_token: job_token)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'responds with 401 Unauthorized when invalid job token provided' do
+ ping(job_token: 'invalid-token')
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+
+ def ping(personal_access_token: nil, job_token: nil)
+ headers = {
+ Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => personal_access_token.presence,
+ Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER => job_token.presence
+ }.compact
+
+ get api('/projects/%d/packages/generic/ping' % project.id), headers: headers
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb b/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb
index ae1abb50a40..3628171fcc1 100644
--- a/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb
+++ b/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe 'get board lists' do
nodes {
lists {
nodes {
- issues {
+ issues(filters: {labelName: "#{label2.title}"}) {
count
nodes {
#{all_graphql_fields_for('issues'.classify)}
@@ -51,8 +51,8 @@ RSpec.describe 'get board lists' do
shared_examples 'group and project board list issues query' do
let!(:board) { create(:board, resource_parent: board_parent) }
let!(:label_list) { create(:list, board: board, label: label, position: 10) }
- let!(:issue1) { create(:issue, project: issue_project, labels: [label], relative_position: 9) }
- let!(:issue2) { create(:issue, project: issue_project, labels: [label], relative_position: 2) }
+ let!(:issue1) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 9) }
+ let!(:issue2) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 2) }
let!(:issue3) { create(:issue, project: issue_project, labels: [label], relative_position: 9) }
let!(:issue4) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) }
@@ -72,7 +72,7 @@ RSpec.describe 'get board lists' do
it 'can access the issues' do
post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user)
- expect(issue_titles).to eq([issue2.title, issue3.title, issue1.title])
+ expect(issue_titles).to eq([issue2.title, issue1.title])
end
end
end
diff --git a/spec/requests/api/graphql/current_user_todos_spec.rb b/spec/requests/api/graphql/current_user_todos_spec.rb
new file mode 100644
index 00000000000..b657f15d0e9
--- /dev/null
+++ b/spec/requests/api/graphql/current_user_todos_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'A Todoable that implements the CurrentUserTodos interface' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:todoable) { create(:issue, project: project) }
+ let_it_be(:done_todo) { create(:todo, state: :done, target: todoable, user: current_user) }
+ let_it_be(:pending_todo) { create(:todo, state: :pending, target: todoable, user: current_user) }
+ let(:state) { 'null' }
+
+ let(:todoable_response) do
+ graphql_data_at(:project, :issue, :currentUserTodos, :nodes)
+ end
+
+ let(:query) do
+ <<~GQL
+ {
+ project(fullPath: "#{project.full_path}") {
+ issue(iid: "#{todoable.iid}") {
+ currentUserTodos(state: #{state}) {
+ nodes {
+ #{all_graphql_fields_for('Todo', max_depth: 1)}
+ }
+ }
+ }
+ }
+ }
+ GQL
+ end
+
+ it 'returns todos of the current user' do
+ post_graphql(query, current_user: current_user)
+
+ expect(todoable_response).to contain_exactly(
+ a_hash_including('id' => global_id_of(done_todo)),
+ a_hash_including('id' => global_id_of(pending_todo))
+ )
+ end
+
+ it 'does not return todos of another user', :aggregate_failures do
+ post_graphql(query, current_user: create(:user))
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(todoable_response).to be_empty
+ end
+
+ it 'does not error when there is no logged in user', :aggregate_failures do
+ post_graphql(query)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(todoable_response).to be_empty
+ end
+
+ context 'when `state` argument is `pending`' do
+ let(:state) { 'pending' }
+
+ it 'returns just the pending todo' do
+ post_graphql(query, current_user: current_user)
+
+ expect(todoable_response).to contain_exactly(
+ a_hash_including('id' => global_id_of(pending_todo))
+ )
+ end
+ end
+
+ context 'when `state` argument is `done`' do
+ let(:state) { 'done' }
+
+ it 'returns just the done todo' do
+ post_graphql(query, current_user: current_user)
+
+ expect(todoable_response).to contain_exactly(
+ a_hash_including('id' => global_id_of(done_todo))
+ )
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/group/group_members_spec.rb b/spec/requests/api/graphql/group/group_members_spec.rb
new file mode 100644
index 00000000000..84b2fd63d46
--- /dev/null
+++ b/spec/requests/api/graphql/group/group_members_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting group members information' do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user_1) { create(:user, username: 'user') }
+ let_it_be(:user_2) { create(:user, username: 'test') }
+
+ let(:member_data) { graphql_data['group']['groupMembers']['edges'] }
+
+ before do
+ [user_1, user_2].each { |user| group.add_guest(user) }
+ end
+
+ context 'when the request is correct' do
+ it_behaves_like 'a working graphql query' do
+ before do
+ fetch_members(user)
+ end
+ end
+
+ it 'returns group members successfully' do
+ fetch_members(user)
+
+ expect(graphql_errors).to be_nil
+ expect_array_response(user_1.to_global_id.to_s, user_2.to_global_id.to_s)
+ end
+
+ it 'returns members that match the search query' do
+ fetch_members(user, { search: 'test' })
+
+ expect(graphql_errors).to be_nil
+ expect_array_response(user_2.to_global_id.to_s)
+ end
+ end
+
+ def fetch_members(user = nil, args = {})
+ post_graphql(members_query(args), current_user: user)
+ end
+
+ def members_query(args = {})
+ members_node = <<~NODE
+ edges {
+ node {
+ user {
+ id
+ }
+ }
+ }
+ NODE
+
+ graphql_query_for("group",
+ { full_path: group.full_path },
+ [query_graphql_field("groupMembers", args, members_node)]
+ )
+ end
+
+ def expect_array_response(*items)
+ expect(response).to have_gitlab_http_status(:success)
+ expect(member_data).to be_an Array
+ expect(member_data.map { |node| node["node"]["user"]["id"] }).to match_array(items)
+ end
+end
diff --git a/spec/requests/api/graphql/group_query_spec.rb b/spec/requests/api/graphql/group_query_spec.rb
index d99bff2e349..83180c7d7a5 100644
--- a/spec/requests/api/graphql/group_query_spec.rb
+++ b/spec/requests/api/graphql/group_query_spec.rb
@@ -89,18 +89,13 @@ RSpec.describe 'getting group information', :do_not_mock_admin_mode do
end
it 'avoids N+1 queries' do
- control_count = ActiveRecord::QueryRecorder.new do
- post_graphql(group_query(group1), current_user: admin)
- end.count
+ pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/245272')
queries = [{ query: group_query(group1) },
{ query: group_query(group2) }]
- expect do
- post_multiplex(queries, current_user: admin)
- end.not_to exceed_query_limit(control_count)
-
- expect(graphql_errors).to contain_exactly(nil, nil)
+ expect { post_multiplex(queries, current_user: admin) }
+ .to issue_same_number_of_queries_as { post_graphql(group_query(group1), current_user: admin) }
end
end
diff --git a/spec/requests/api/graphql/instance_statistics_measurements_spec.rb b/spec/requests/api/graphql/instance_statistics_measurements_spec.rb
new file mode 100644
index 00000000000..b8cbe54534a
--- /dev/null
+++ b/spec/requests/api/graphql/instance_statistics_measurements_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'InstanceStatisticsMeasurements' do
+ include GraphqlHelpers
+
+ let(:current_user) { create(:user, :admin) }
+ let!(:instance_statistics_measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 20.days.ago, count: 5) }
+ let!(:instance_statistics_measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago, count: 10) }
+
+ let(:query) { graphql_query_for(:instanceStatisticsMeasurements, 'identifier: PROJECTS', 'nodes { count }') }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns measurement objects' do
+ expect(graphql_data.dig('instanceStatisticsMeasurements', 'nodes')).to eq([{ "count" => 10 }, { "count" => 5 }])
+ end
+end
diff --git a/spec/requests/api/graphql/issue/issue_spec.rb b/spec/requests/api/graphql/issue/issue_spec.rb
new file mode 100644
index 00000000000..1c9d6b25856
--- /dev/null
+++ b/spec/requests/api/graphql/issue/issue_spec.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.issue(id)' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:current_user) { create(:user) }
+
+ let(:issue_data) { graphql_data['issue'] }
+
+ let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } }
+ let(:issue_fields) { all_graphql_fields_for('Issue'.classify) }
+
+ let(:query) do
+ graphql_query_for('issue', issue_params, issue_fields)
+ end
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+ end
+
+ context 'when the user does not have access to the issue' do
+ it 'returns nil' do
+ project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
+
+ post_graphql(query)
+
+ expect(issue_data).to be nil
+ end
+ end
+
+ context 'when the user does have access' do
+ before do
+ project.add_guest(current_user)
+ end
+
+ it 'returns the issue' do
+ post_graphql(query, current_user: current_user)
+
+ expect(issue_data).to include(
+ 'title' => issue.title,
+ 'description' => issue.description
+ )
+ end
+
+ context 'selecting any single field' do
+ where(:field) do
+ scalar_fields_of('Issue').map { |name| [name] }
+ end
+
+ with_them do
+ it_behaves_like 'a working graphql query' do
+ let(:issue_fields) do
+ field
+ end
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it "returns the Issue and field #{params['field']}" do
+ expect(issue_data.keys).to eq([field])
+ end
+ end
+ end
+ end
+
+ context 'selecting multiple fields' do
+ let(:issue_fields) { %w(title description) }
+
+ it 'returns the Issue with the specified fields' do
+ post_graphql(query, current_user: current_user)
+
+ expect(issue_data.keys).to eq( %w(title description) )
+ expect(issue_data['title']).to eq(issue.title)
+ expect(issue_data['description']).to eq(issue.description)
+ end
+ end
+
+ context 'when passed a non-Issue gid' do
+ let(:mr) {create(:merge_request)}
+
+ it 'returns an error' do
+ gid = mr.to_global_id.to_s
+ issue_params['id'] = gid
+
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_errors).not_to be nil
+ expect(graphql_errors.first['message']).to eq("\"#{gid}\" does not represent an instance of Issue")
+ end
+ end
+ end
+
+ context 'when there is a confidential issue' do
+ let!(:confidential_issue) do
+ create(:issue, :confidential, project: project)
+ end
+
+ let(:issue_params) { { 'id' => confidential_issue.to_global_id.to_s } }
+
+ context 'when the user cannot see confidential issues' do
+ it 'returns nil ' do
+ post_graphql(query, current_user: current_user)
+
+ expect(issue_data).to be nil
+ end
+ end
+
+ context 'when the user can see confidential issues' do
+ it 'returns the confidential issue' do
+ project.add_developer(current_user)
+
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_data.count).to eq(1)
+ expect(issue_data['confidential']).to be(true)
+ end
+ 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 456b0a5dea1..e01f59ee6a0 100644
--- a/spec/requests/api/graphql/metrics/dashboard_query_spec.rb
+++ b/spec/requests/api/graphql/metrics/dashboard_query_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe 'Getting Metrics Dashboard' do
let_it_be(:current_user) { create(:user) }
let(:project) { create(:project) }
- let!(:environment) { create(:environment, project: project) }
+ let(:environment) { create(:environment, project: project) }
let(:query) do
graphql_query_for(
@@ -25,73 +25,156 @@ RSpec.describe 'Getting Metrics Dashboard' do
)
end
- context 'for anonymous user' do
+ context 'with metrics_dashboard_exhaustive_validations feature flag off' do
before do
- post_graphql(query, current_user: current_user)
+ stub_feature_flags(metrics_dashboard_exhaustive_validations: false)
end
- context 'requested dashboard is available' do
- let(:path) { 'config/prometheus/common_metrics.yml' }
+ context 'for anonymous user' do
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ context 'requested dashboard is available' do
+ let(:path) { 'config/prometheus/common_metrics.yml' }
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns nil' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes')
+
+ expect(dashboard).to be_nil
+ end
+ end
+ end
+
+ context 'for user with developer access' do
+ before do
+ project.add_developer(current_user)
+ post_graphql(query, current_user: current_user)
+ end
+
+ context 'requested dashboard is available' do
+ let(:path) { 'config/prometheus/common_metrics.yml' }
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns metrics dashboard' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
+
+ expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => nil)
+ end
+
+ context 'invalid dashboard' do
+ let(:path) { '.gitlab/dashboards/metrics.yml' }
+ let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "---\ndashboard: 'test'" }) }
+
+ it 'returns metrics dashboard' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
+
+ expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["panel_groups: should be an array of panel_groups objects"])
+ end
+ end
+
+ context 'empty dashboard' do
+ let(:path) { '.gitlab/dashboards/metrics.yml' }
+ let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "" }) }
+
+ it 'returns metrics dashboard' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
- it_behaves_like 'a working graphql query'
+ expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["dashboard: can't be blank", "panel_groups: should be an array of panel_groups objects"])
+ end
+ end
+ end
+
+ context 'requested dashboard can not be found' do
+ let(:path) { 'config/prometheus/i_am_not_here.yml' }
- it 'returns nil' do
- dashboard = graphql_data.dig('project', 'environments', 'nodes')
+ it_behaves_like 'a working graphql query'
- expect(dashboard).to be_nil
+ it 'returns nil' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
+
+ expect(dashboard).to be_nil
+ end
end
end
end
- context 'for user with developer access' do
+ context 'with metrics_dashboard_exhaustive_validations feature flag on' do
before do
- project.add_developer(current_user)
- post_graphql(query, current_user: current_user)
+ stub_feature_flags(metrics_dashboard_exhaustive_validations: true)
end
- context 'requested dashboard is available' do
- let(:path) { 'config/prometheus/common_metrics.yml' }
+ context 'for anonymous user' do
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ context 'requested dashboard is available' do
+ let(:path) { 'config/prometheus/common_metrics.yml' }
+
+ it_behaves_like 'a working graphql query'
- it_behaves_like 'a working graphql query'
+ it 'returns nil' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes')
- it 'returns metrics dashboard' do
- dashboard = graphql_data.dig('project', 'environments', 'nodes')[0]['metricsDashboard']
+ expect(dashboard).to be_nil
+ end
+ end
+ end
- expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => nil)
+ context 'for user with developer access' do
+ before do
+ project.add_developer(current_user)
+ post_graphql(query, current_user: current_user)
end
- context 'invalid dashboard' do
- let(:path) { '.gitlab/dashboards/metrics.yml' }
- let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "---\ndashboard: 'test'" }) }
+ context 'requested dashboard is available' do
+ let(:path) { 'config/prometheus/common_metrics.yml' }
+
+ it_behaves_like 'a working graphql query'
it 'returns metrics dashboard' do
dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
- expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["panel_groups: should be an array of panel_groups objects"])
+ expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => nil)
end
- end
- context 'empty dashboard' do
- let(:path) { '.gitlab/dashboards/metrics.yml' }
- let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "" }) }
+ context 'invalid dashboard' do
+ let(:path) { '.gitlab/dashboards/metrics.yml' }
+ let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "---\ndashboard: 'test'" }) }
- it 'returns metrics dashboard' do
- dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
+ it 'returns metrics dashboard' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
- expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["dashboard: can't be blank", "panel_groups: should be an array of panel_groups objects"])
+ expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["root is missing required keys: panel_groups"])
+ end
+ end
+
+ context 'empty dashboard' do
+ let(:path) { '.gitlab/dashboards/metrics.yml' }
+ let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "" }) }
+
+ it 'returns metrics dashboard' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
+
+ expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["root is missing required keys: dashboard, panel_groups"])
+ end
end
end
- end
- context 'requested dashboard can not be found' do
- let(:path) { 'config/prometheus/i_am_not_here.yml' }
+ context 'requested dashboard can not be found' do
+ let(:path) { 'config/prometheus/i_am_not_here.yml' }
- it_behaves_like 'a working graphql query'
+ it_behaves_like 'a working graphql query'
- it 'returns nil' do
- dashboard = graphql_data.dig('project', 'environments', 'nodes')[0]['metricsDashboard']
+ it 'returns nil' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
- expect(dashboard).to be_nil
+ expect(dashboard).to be_nil
+ end
end
end
end
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 1891300dace..1d38bb39d59 100644
--- a/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb
+++ b/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb
@@ -32,9 +32,7 @@ RSpec.describe 'Adding an AwardEmoji' do
context 'when the user does not have permission' do
it_behaves_like 'a mutation that does not create an AwardEmoji'
-
- 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when the user has permission' do
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 665b511abb8..c6e8800de1f 100644
--- a/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb
+++ b/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb
@@ -33,9 +33,7 @@ RSpec.describe 'Removing an AwardEmoji' do
shared_examples 'a mutation that does not authorize the user' do
it_behaves_like 'a mutation that does not destroy an AwardEmoji'
-
- 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when the current_user does not own the award emoji' do
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 ab4a213fde3..2df59ce97ca 100644
--- a/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb
+++ b/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb
@@ -143,8 +143,6 @@ RSpec.describe 'Toggling an AwardEmoji' do
context 'when the user does not have permission' do
it_behaves_like 'a mutation that does not create or destroy an AwardEmoji'
-
- 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
end
end
diff --git a/spec/requests/api/graphql/mutations/boards/destroy_spec.rb b/spec/requests/api/graphql/mutations/boards/destroy_spec.rb
new file mode 100644
index 00000000000..a6d894e698d
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/boards/destroy_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Boards::Destroy do
+ include GraphqlHelpers
+
+ let_it_be(:current_user, reload: true) { create(:user) }
+ let_it_be(:project, reload: true) { create(:project) }
+ let_it_be(:board) { create(:board, project: project) }
+ let_it_be(:other_board) { create(:board, project: project) }
+ let(:mutation) do
+ variables = {
+ id: GitlabSchema.id_from_object(board).to_s
+ }
+
+ graphql_mutation(:destroy_board, variables)
+ end
+
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
+ def mutation_response
+ graphql_mutation_response(:destroy_board)
+ end
+
+ context 'when the user does not have permission' do
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not destroy the board' do
+ expect { subject }.not_to change { Board.count }
+ end
+ end
+
+ context 'when the user has permission' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ context 'when given id is not for a board' do
+ let_it_be(:board) { build_stubbed(:issue, project: project) }
+
+ it 'returns an error' do
+ subject
+
+ expect(graphql_errors.first['message']).to include('does not represent an instance of Board')
+ end
+ end
+
+ context 'when everything is ok' do
+ it 'destroys the board' do
+ expect { subject }.to change { Board.count }.from(2).to(1)
+ end
+
+ it 'returns an empty board' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(mutation_response).to have_key('board')
+ expect(mutation_response['board']).to be_nil
+ end
+ end
+
+ context 'when there is only 1 board for the parent' do
+ before do
+ other_board.destroy!
+ end
+
+ it 'does not destroy the board' do
+ expect { subject }.not_to change { Board.count }.from(1)
+ end
+
+ it 'returns an error and not nil board' do
+ subject
+
+ expect(mutation_response['errors']).not_to be_empty
+ expect(mutation_response['board']).not_to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/boards/lists/create_spec.rb b/spec/requests/api/graphql/mutations/boards/lists/create_spec.rb
new file mode 100644
index 00000000000..328f4fb7b6e
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/boards/lists/create_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create a label or backlog board list' do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:board) { create(:board, group: group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:dev_label) do
+ create(:group_label, title: 'Development', color: '#FFAABB', group: group)
+ end
+
+ let(:current_user) { user }
+ let(:mutation) { graphql_mutation(:board_list_create, input) }
+ let(:mutation_response) { graphql_mutation_response(:board_list_create) }
+
+ context 'the user is not allowed to read board lists' do
+ let(:input) { { board_id: board.to_global_id.to_s, backlog: true } }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user has permissions to admin board lists' do
+ before do
+ group.add_reporter(current_user)
+ end
+
+ describe 'backlog list' do
+ let(:input) { { board_id: board.to_global_id.to_s, backlog: true } }
+
+ it 'creates the list' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['list'])
+ .to include('position' => nil, 'listType' => 'backlog')
+ end
+ end
+
+ describe 'label list' do
+ let(:input) { { board_id: board.to_global_id.to_s, label_id: dev_label.to_global_id.to_s } }
+
+ it 'creates the list' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['list'])
+ .to include('position' => 0, 'listType' => 'label', 'label' => include('title' => 'Development'))
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/boards/lists/update_spec.rb b/spec/requests/api/graphql/mutations/boards/lists/update_spec.rb
index 8a6d2cb3994..8e24e053211 100644
--- a/spec/requests/api/graphql/mutations/boards/lists/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/boards/lists/update_spec.rb
@@ -15,8 +15,7 @@ RSpec.describe 'Update of an existing board list' do
let(:mutation_response) { graphql_mutation_response(:update_board_list) }
context 'the user is not allowed to read board lists' 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
end
before do
diff --git a/spec/requests/api/graphql/mutations/branches/create_spec.rb b/spec/requests/api/graphql/mutations/branches/create_spec.rb
index 082b445bf3e..fc09f57a389 100644
--- a/spec/requests/api/graphql/mutations/branches/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/branches/create_spec.rb
@@ -15,8 +15,7 @@ RSpec.describe 'Creation of a new branch' do
let(:mutation_response) { graphql_mutation_response(:create_branch) }
context 'the user is not allowed to create a branch' 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to create a branch' do
diff --git a/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb b/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb
new file mode 100644
index 00000000000..a20ac823550
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'PipelineCancel' 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(:mutation) do
+ variables = {
+ id: pipeline.to_global_id.to_s
+ }
+ graphql_mutation(:pipeline_cancel, variables, 'errors')
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:pipeline_cancel) }
+
+ before_all do
+ project.add_maintainer(user)
+ end
+
+ it 'does not cancel any pipelines not owned by the current user' do
+ build = create(:ci_build, :running, pipeline: pipeline)
+
+ post_graphql_mutation(mutation, current_user: create(:user))
+
+ expect(graphql_errors).not_to be_empty
+ expect(build).not_to be_canceled
+ end
+
+ it 'returns a error if the pipline cannot be be canceled' do
+ build = create(:ci_build, :success, pipeline: pipeline)
+
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(mutation_response).to include('errors' => include(eq 'Pipeline is not cancelable'))
+ expect(build).not_to be_canceled
+ end
+
+ it "cancels all cancelable builds from a pipeline" do
+ build = create(:ci_build, :running, pipeline: pipeline)
+
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(build.reload).to be_canceled
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb b/spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb
new file mode 100644
index 00000000000..08959d354e2
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'PipelineDestroy' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { project.owner }
+ let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project, user: user) }
+
+ let(:mutation) do
+ variables = {
+ id: pipeline.to_global_id.to_s
+ }
+ graphql_mutation(:pipeline_destroy, variables, 'errors')
+ end
+
+ it 'returns an error if the user is not allowed to destroy the pipeline' do
+ post_graphql_mutation(mutation, current_user: create(:user))
+
+ expect(graphql_errors).not_to be_empty
+ end
+
+ it 'destroys a pipeline' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect { pipeline.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/ci/pipeline_retry_spec.rb b/spec/requests/api/graphql/mutations/ci/pipeline_retry_spec.rb
new file mode 100644
index 00000000000..f6acf29c321
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/pipeline_retry_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'PipelineRetry' 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(:mutation) do
+ variables = {
+ id: pipeline.to_global_id.to_s
+ }
+ graphql_mutation(:pipeline_retry, variables,
+ <<-QL
+ errors
+ pipeline {
+ id
+ }
+ QL
+ )
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:pipeline_retry) }
+
+ before_all do
+ project.add_maintainer(user)
+ end
+
+ it 'returns an error if the user is not allowed to retry the pipeline' do
+ post_graphql_mutation(mutation, current_user: create(:user))
+
+ expect(graphql_errors).not_to be_empty
+ end
+
+ it 'retries a pipeline' do
+ pipeline_id = ::Gitlab::GlobalId.build(pipeline, id: pipeline.id).to_s
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['pipeline']['id']).to eq(pipeline_id)
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/commits/create_spec.rb b/spec/requests/api/graphql/mutations/commits/create_spec.rb
index 9e4a96700bb..ac4fa7cfe83 100644
--- a/spec/requests/api/graphql/mutations/commits/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/commits/create_spec.rb
@@ -24,8 +24,7 @@ RSpec.describe 'Creation of a new commit' do
let(:mutation_response) { graphql_mutation_response(:commit_create) }
context 'the user is not allowed to create a commit' 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to create a commit' do
diff --git a/spec/requests/api/graphql/mutations/design_management/upload_spec.rb b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb
index 9a9c7107b20..2189ae3c519 100644
--- a/spec/requests/api/graphql/mutations/design_management/upload_spec.rb
+++ b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb
@@ -12,11 +12,11 @@ RSpec.describe "uploading designs" do
let(:files) { [fixture_file_upload("spec/fixtures/dk.png")] }
let(:variables) { {} }
- let(:mutation) do
+ def mutation
input = {
project_path: project.full_path,
iid: issue.iid,
- files: files
+ files: files.dup
}.merge(variables)
graphql_mutation(:design_management_upload, input)
end
@@ -30,31 +30,15 @@ RSpec.describe "uploading designs" do
end
it "returns an error if the user is not allowed to upload designs" do
- post_graphql_mutation(mutation, current_user: create(:user))
+ post_graphql_mutation_with_uploads(mutation, current_user: create(:user))
expect(graphql_errors).to be_present
end
- it "succeeds (backward compatibility)" do
- post_graphql_mutation(mutation, current_user: current_user)
+ it "succeeds, and responds with the created designs" do
+ post_graphql_mutation_with_uploads(mutation, current_user: current_user)
expect(graphql_errors).not_to be_present
- end
-
- it 'succeeds' do
- file_path_in_params = ['designManagementUploadInput', 'files', 0]
- params = mutation_to_apollo_uploads_param(mutation, files: [file_path_in_params])
-
- workhorse_post_with_file(api('/', current_user, version: 'graphql'),
- params: params,
- file_key: '1'
- )
-
- expect(graphql_errors).not_to be_present
- end
-
- it "responds with the created designs" do
- post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response).to include(
"designs" => a_collection_containing_exactly(
@@ -65,7 +49,7 @@ RSpec.describe "uploading designs" do
it "can respond with skipped designs" do
2.times do
- post_graphql_mutation(mutation, current_user: current_user)
+ post_graphql_mutation_with_uploads(mutation, current_user: current_user)
files.each(&:rewind)
end
@@ -80,7 +64,7 @@ RSpec.describe "uploading designs" do
let(:variables) { { iid: "123" } }
it "returns an error" do
- post_graphql_mutation(mutation, current_user: create(:user))
+ post_graphql_mutation_with_uploads(mutation, current_user: create(:user))
expect(graphql_errors).not_to be_empty
end
@@ -92,7 +76,7 @@ RSpec.describe "uploading designs" do
expect(service).to receive(:execute).and_return({ status: :error, message: "Something went wrong" })
end
- post_graphql_mutation(mutation, current_user: current_user)
+ post_graphql_mutation_with_uploads(mutation, current_user: current_user)
expect(mutation_response["errors"].first).to eq("Something went wrong")
end
end
diff --git a/spec/requests/api/graphql/mutations/discussions/toggle_resolve_spec.rb b/spec/requests/api/graphql/mutations/discussions/toggle_resolve_spec.rb
index 457c37e900b..450996bf76b 100644
--- a/spec/requests/api/graphql/mutations/discussions/toggle_resolve_spec.rb
+++ b/spec/requests/api/graphql/mutations/discussions/toggle_resolve_spec.rb
@@ -20,8 +20,7 @@ RSpec.describe 'Toggling the resolve status of a discussion' do
context 'when the user does not have permission' do
let_it_be(:current_user) { create(:user) }
- 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"]
+ it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permission' do
diff --git a/spec/requests/api/graphql/mutations/issues/set_locked_spec.rb b/spec/requests/api/graphql/mutations/issues/set_locked_spec.rb
index f1d55430e02..4989d096925 100644
--- a/spec/requests/api/graphql/mutations/issues/set_locked_spec.rb
+++ b/spec/requests/api/graphql/mutations/issues/set_locked_spec.rb
@@ -32,12 +32,7 @@ RSpec.describe 'Setting an issue as locked' do
end
context 'when the user is not allowed to update the issue' do
- it 'returns an error' do
- error = "The resource that you are attempting to access does not exist or you don't have permission to perform this action"
- post_graphql_mutation(mutation, current_user: current_user)
-
- expect(graphql_errors).to include(a_hash_including('message' => error))
- end
+ it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user is allowed to update the issue' do
diff --git a/spec/requests/api/graphql/mutations/issues/set_severity_spec.rb b/spec/requests/api/graphql/mutations/issues/set_severity_spec.rb
new file mode 100644
index 00000000000..96fd2368765
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/issues/set_severity_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Setting severity level of an incident' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let(:incident) { create(:incident) }
+ let(:project) { incident.project }
+ let(:input) { { severity: 'CRITICAL' } }
+
+ let(:mutation) do
+ variables = {
+ project_path: project.full_path,
+ iid: incident.iid.to_s
+ }
+
+ graphql_mutation(:issue_set_severity, variables.merge(input),
+ <<-QL.strip_heredoc
+ clientMutationId
+ errors
+ issue {
+ iid
+ severity
+ }
+ QL
+ )
+ end
+
+ def mutation_response
+ graphql_mutation_response(:issue_set_severity)
+ end
+
+ context 'when the user is not allowed to update the incident' do
+ it 'returns an error' do
+ error = "The resource that you are attempting to access does not exist or you don't have permission to perform this action"
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(graphql_errors).to include(a_hash_including('message' => error))
+ end
+ end
+
+ context 'when the user is allowed to update the incident' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'updates the issue' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response.dig('issue', 'severity')).to eq('CRITICAL')
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/issues/update_spec.rb b/spec/requests/api/graphql/mutations/issues/update_spec.rb
index fd983c683be..af52f9d57a3 100644
--- a/spec/requests/api/graphql/mutations/issues/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/issues/update_spec.rb
@@ -20,8 +20,7 @@ RSpec.describe 'Update of an existing issue' do
let(:mutation_response) { graphql_mutation_response(:update_issue) }
context 'the user is not allowed to update issue' 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to update issue' do
diff --git a/spec/requests/api/graphql/mutations/merge_requests/create_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/create_spec.rb
index 9297ca054c7..bf759521dc0 100644
--- a/spec/requests/api/graphql/mutations/merge_requests/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/merge_requests/create_spec.rb
@@ -24,8 +24,7 @@ RSpec.describe 'Creation of a new merge request' do
let(:mutation_response) { graphql_mutation_response(:merge_request_create) }
context 'the user is not allowed to create a branch' 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to create a merge request' 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 0e2da94f0f9..10ca2cf1cf8 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
@@ -101,7 +101,7 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create do
graphql_mutation(:create_annotation, variables)
end
- it_behaves_like 'a mutation that returns top-level errors', errors: ['invalid_id is not a valid GitLab id.']
+ it_behaves_like 'a mutation that returns top-level errors', errors: ['invalid_id is not a valid GitLab ID.']
end
end
end
@@ -188,7 +188,7 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create do
graphql_mutation(:create_annotation, variables)
end
- it_behaves_like 'a mutation that returns top-level errors', errors: ['invalid_id is not a valid GitLab id.']
+ it_behaves_like 'a mutation that returns top-level errors', errors: ['invalid_id is not a valid GitLab ID.']
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 2459a6f3828..7357f3e1e35 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
@@ -45,7 +45,7 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete do
graphql_mutation(:delete_annotation, variables)
end
- it_behaves_like 'a mutation that returns top-level errors', errors: ['invalid_id is not a valid GitLab id.']
+ it_behaves_like 'a mutation that returns top-level errors', errors: ['invalid_id is not a valid GitLab ID.']
end
context 'when the delete fails' do
diff --git a/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb
index e847c46be1b..21da1332465 100644
--- a/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe 'Adding a DiffNote' do
it_behaves_like 'a Note mutation when there are active record validation errors', model: DiffNote
context do
- let(:diff_refs) { build(:merge_request).diff_refs } # Allow fake diff refs so arguments are valid
+ let(:diff_refs) { build(:commit).diff_refs } # Allow fake diff refs so arguments are valid
it_behaves_like 'a Note mutation when the given resource id is not for a Noteable'
end
diff --git a/spec/requests/api/graphql/mutations/notes/create/image_diff_note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/image_diff_note_spec.rb
index 896a398e308..8bc68e6017c 100644
--- a/spec/requests/api/graphql/mutations/notes/create/image_diff_note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/create/image_diff_note_spec.rb
@@ -47,7 +47,7 @@ RSpec.describe 'Adding an image DiffNote' do
it_behaves_like 'a Note mutation when there are active record validation errors', model: DiffNote
context do
- let(:diff_refs) { build(:merge_request).diff_refs } # Allow fake diff refs so arguments are valid
+ let(:diff_refs) { build(:commit).diff_refs } # Allow fake diff refs so arguments are valid
it_behaves_like 'a Note mutation when the given resource id is not for a Noteable'
end
diff --git a/spec/requests/api/graphql/mutations/notes/destroy_spec.rb b/spec/requests/api/graphql/mutations/notes/destroy_spec.rb
index 6002a5b5b9d..49f09fadfea 100644
--- a/spec/requests/api/graphql/mutations/notes/destroy_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/destroy_spec.rb
@@ -21,8 +21,7 @@ RSpec.describe 'Destroying a Note' do
context 'when the user does not have permission' do
let(:current_user) { create(:user) }
- 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
it 'does not destroy the Note' do
expect do
diff --git a/spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb b/spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb
index 463a872d95d..0c00906d6bf 100644
--- a/spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb
@@ -59,8 +59,7 @@ RSpec.describe 'Updating an image DiffNote' do
context 'when the user does not have permission' do
let_it_be(:current_user) { create(:user) }
- 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
it 'does not update the DiffNote' do
post_graphql_mutation(mutation, current_user: current_user)
diff --git a/spec/requests/api/graphql/mutations/notes/update/note_spec.rb b/spec/requests/api/graphql/mutations/notes/update/note_spec.rb
index 0d93afe9434..5a92ffe61b8 100644
--- a/spec/requests/api/graphql/mutations/notes/update/note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/update/note_spec.rb
@@ -22,8 +22,7 @@ RSpec.describe 'Updating a Note' do
context 'when the user does not have permission' do
let_it_be(:current_user) { create(:user) }
- 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
it 'does not update the Note' do
post_graphql_mutation(mutation, current_user: current_user)
diff --git a/spec/requests/api/graphql/mutations/snippets/create_spec.rb b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
index 56a5f4907c1..1bb446de708 100644
--- a/spec/requests/api/graphql/mutations/snippets/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
@@ -7,22 +7,24 @@ RSpec.describe 'Creating a Snippet' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
- let(:content) { 'Initial content' }
+
let(:description) { 'Initial description' }
let(:title) { 'Initial title' }
- let(:file_name) { 'Initial file_name' }
let(:visibility_level) { 'public' }
+ let(:action) { :create }
+ let(:file_1) { { filePath: 'example_file1', content: 'This is the example file 1' }}
+ let(:file_2) { { filePath: 'example_file2', content: 'This is the example file 2' }}
+ let(:actions) { [{ action: action }.merge(file_1), { action: action }.merge(file_2)] }
let(:project_path) { nil }
let(:uploaded_files) { nil }
let(:mutation_vars) do
{
- content: content,
description: description,
visibility_level: visibility_level,
- file_name: file_name,
title: title,
project_path: project_path,
- uploaded_files: uploaded_files
+ uploaded_files: uploaded_files,
+ blob_actions: actions
}
end
@@ -62,24 +64,47 @@ RSpec.describe 'Creating a Snippet' do
context 'when the user has permission' do
let(:current_user) { user }
- context 'with PersonalSnippet' do
- it 'creates the Snippet' do
+ shared_examples 'does not create snippet' do
+ it 'does not create the Snippet' do
expect do
subject
- end.to change { Snippet.count }.by(1)
+ end.not_to change { Snippet.count }
end
- it 'returns the created Snippet' do
+ it 'does not return Snippet' do
subject
- expect(mutation_response['snippet']['blob']['richData']).to be_nil
- expect(mutation_response['snippet']['blob']['plainData']).to match(content)
+ expect(mutation_response['snippet']).to be_nil
+ end
+ end
+
+ shared_examples 'creates snippet' do
+ it 'returns the created Snippet' do
+ expect do
+ subject
+ end.to change { Snippet.count }.by(1)
+
expect(mutation_response['snippet']['title']).to eq(title)
expect(mutation_response['snippet']['description']).to eq(description)
- expect(mutation_response['snippet']['fileName']).to eq(file_name)
expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level)
- expect(mutation_response['snippet']['project']).to be_nil
+ expect(mutation_response['snippet']['blobs'][0]['plainData']).to match(file_1[:content])
+ expect(mutation_response['snippet']['blobs'][0]['fileName']).to match(file_1[:file_path])
+ expect(mutation_response['snippet']['blobs'][1]['plainData']).to match(file_2[:content])
+ expect(mutation_response['snippet']['blobs'][1]['fileName']).to match(file_2[:file_path])
end
+
+ context 'when action is invalid' do
+ let(:file_1) { { filePath: 'example_file1' }}
+
+ it_behaves_like 'a mutation that returns errors in the response', errors: ['Snippet actions have invalid data']
+ it_behaves_like 'does not create snippet'
+ end
+
+ it_behaves_like 'snippet edit usage data counters'
+ end
+
+ context 'with PersonalSnippet' do
+ it_behaves_like 'creates snippet'
end
context 'with ProjectSnippet' do
@@ -89,23 +114,7 @@ RSpec.describe 'Creating a Snippet' do
project.add_developer(current_user)
end
- it 'creates the Snippet' do
- expect do
- subject
- end.to change { Snippet.count }.by(1)
- end
-
- it 'returns the created Snippet' do
- subject
-
- expect(mutation_response['snippet']['blob']['richData']).to be_nil
- expect(mutation_response['snippet']['blob']['plainData']).to match(content)
- expect(mutation_response['snippet']['title']).to eq(title)
- expect(mutation_response['snippet']['description']).to eq(description)
- expect(mutation_response['snippet']['fileName']).to eq(file_name)
- expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level)
- expect(mutation_response['snippet']['project']['fullPath']).to eq(project_path)
- end
+ it_behaves_like 'creates snippet'
context 'when the project path is invalid' do
let(:project_path) { 'foobar' }
@@ -122,61 +131,8 @@ RSpec.describe 'Creating a Snippet' do
it_behaves_like 'a mutation that returns top-level errors',
errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
end
- end
-
- shared_examples 'does not create snippet' do
- it 'does not create the Snippet' do
- expect do
- subject
- end.not_to change { Snippet.count }
- end
-
- it 'does not return Snippet' do
- subject
-
- expect(mutation_response['snippet']).to be_nil
- end
- end
-
- context 'when snippet is created using the files param' do
- let(:action) { :create }
- let(:file_1) { { filePath: 'example_file1', content: 'This is the example file 1' }}
- let(:file_2) { { filePath: 'example_file2', content: 'This is the example file 2' }}
- let(:actions) { [{ action: action }.merge(file_1), { action: action }.merge(file_2)] }
- let(:mutation_vars) do
- {
- description: description,
- visibility_level: visibility_level,
- project_path: project_path,
- title: title,
- blob_actions: actions
- }
- end
-
- it 'creates the Snippet' do
- expect do
- subject
- end.to change { Snippet.count }.by(1)
- end
-
- it 'returns the created Snippet' do
- subject
- expect(mutation_response['snippet']['title']).to eq(title)
- expect(mutation_response['snippet']['description']).to eq(description)
- expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level)
- expect(mutation_response['snippet']['blobs'][0]['plainData']).to match(file_1[:content])
- expect(mutation_response['snippet']['blobs'][0]['fileName']).to match(file_1[:file_path])
- expect(mutation_response['snippet']['blobs'][1]['plainData']).to match(file_2[:content])
- expect(mutation_response['snippet']['blobs'][1]['fileName']).to match(file_2[:file_path])
- end
-
- context 'when action is invalid' do
- let(:file_1) { { filePath: 'example_file1' }}
-
- it_behaves_like 'a mutation that returns errors in the response', errors: ['Snippet actions have invalid data']
- it_behaves_like 'does not create snippet'
- end
+ it_behaves_like 'snippet edit usage data counters'
end
context 'when there are ActiveRecord validation errors' do
@@ -187,7 +143,7 @@ RSpec.describe 'Creating a Snippet' do
end
context 'when there non ActiveRecord errors' do
- let(:file_name) { 'invalid://file/path' }
+ let(:file_1) { { filePath: 'invalid://file/path', content: 'foobar' }}
it_behaves_like 'a mutation that returns errors in the response', errors: ['Repository Error creating the snippet - Invalid file name']
it_behaves_like 'does not create snippet'
diff --git a/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb b/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb
index c861564c66b..b71f87d2702 100644
--- a/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb
@@ -56,7 +56,7 @@ RSpec.describe 'Destroying a Snippet' do
post_graphql_mutation(mutation, current_user: current_user)
expect(graphql_errors)
- .to include(a_hash_including('message' => "#{snippet_gid} is not a valid id for Snippet."))
+ .to include(a_hash_including('message' => "#{snippet_gid} is not a valid ID for Snippet."))
end
it 'does not destroy the Snippet' do
diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
index 3f39c0ab851..58ce74b9263 100644
--- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
@@ -12,18 +12,20 @@ RSpec.describe 'Updating a Snippet' do
let(:updated_content) { 'Updated content' }
let(:updated_description) { 'Updated description' }
let(:updated_title) { 'Updated_title' }
- let(:updated_file_name) { 'Updated file_name' }
let(:current_user) { snippet.author }
-
+ let(:updated_file) { 'CHANGELOG' }
+ let(:deleted_file) { 'README' }
let(:snippet_gid) { GitlabSchema.id_from_object(snippet).to_s }
let(:mutation_vars) do
{
id: snippet_gid,
- content: updated_content,
description: updated_description,
visibility_level: 'public',
- file_name: updated_file_name,
- title: updated_title
+ title: updated_title,
+ blob_actions: [
+ { action: :update, filePath: updated_file, content: updated_content },
+ { action: :delete, filePath: deleted_file }
+ ]
}
end
@@ -50,21 +52,32 @@ RSpec.describe 'Updating a Snippet' do
end
context 'when the user has permission' do
- it 'updates the Snippet' do
+ it 'updates the snippet record' do
post_graphql_mutation(mutation, current_user: current_user)
expect(snippet.reload.title).to eq(updated_title)
end
- it 'returns the updated Snippet' do
+ it 'updates the Snippet' do
+ blob_to_update = blob_at(updated_file)
+ blob_to_delete = blob_at(deleted_file)
+
+ expect(blob_to_update.data).not_to eq updated_content
+ expect(blob_to_delete).to be_present
+
post_graphql_mutation(mutation, current_user: current_user)
- expect(mutation_response['snippet']['blob']['richData']).to be_nil
- expect(mutation_response['snippet']['blob']['plainData']).to match(updated_content)
- expect(mutation_response['snippet']['title']).to eq(updated_title)
- expect(mutation_response['snippet']['description']).to eq(updated_description)
- expect(mutation_response['snippet']['fileName']).to eq(updated_file_name)
- expect(mutation_response['snippet']['visibilityLevel']).to eq('public')
+ blob_to_update = blob_at(updated_file)
+ blob_to_delete = blob_at(deleted_file)
+
+ aggregate_failures do
+ expect(blob_to_update.data).to eq updated_content
+ expect(blob_to_delete).to be_nil
+ expect(blob_in_mutation_response(updated_file)['plainData']).to match(updated_content)
+ expect(mutation_response['snippet']['title']).to eq(updated_title)
+ expect(mutation_response['snippet']['description']).to eq(updated_description)
+ expect(mutation_response['snippet']['visibilityLevel']).to eq('public')
+ end
end
context 'when there are ActiveRecord validation errors' do
@@ -79,16 +92,29 @@ RSpec.describe 'Updating a Snippet' do
end
it 'returns the Snippet with its original values' do
+ blob_to_update = blob_at(updated_file)
+ blob_to_delete = blob_at(deleted_file)
+
post_graphql_mutation(mutation, current_user: current_user)
- expect(mutation_response['snippet']['blob']['richData']).to be_nil
- expect(mutation_response['snippet']['blob']['plainData']).to match(original_content)
- expect(mutation_response['snippet']['title']).to eq(original_title)
- expect(mutation_response['snippet']['description']).to eq(original_description)
- expect(mutation_response['snippet']['fileName']).to eq(original_file_name)
- expect(mutation_response['snippet']['visibilityLevel']).to eq('private')
+ aggregate_failures do
+ expect(blob_at(updated_file).data).to eq blob_to_update.data
+ expect(blob_at(deleted_file).data).to eq blob_to_delete.data
+ expect(blob_in_mutation_response(deleted_file)['plainData']).not_to be_nil
+ expect(mutation_response['snippet']['title']).to eq(original_title)
+ expect(mutation_response['snippet']['description']).to eq(original_description)
+ expect(mutation_response['snippet']['visibilityLevel']).to eq('private')
+ end
end
end
+
+ def blob_in_mutation_response(filename)
+ mutation_response['snippet']['blobs'].select { |blob| blob['name'] == filename }[0]
+ end
+
+ def blob_at(filename)
+ snippet.repository.blob_at('HEAD', filename)
+ end
end
end
@@ -96,6 +122,7 @@ RSpec.describe 'Updating a Snippet' do
let(:snippet) do
create(:personal_snippet,
:private,
+ :repository,
file_name: original_file_name,
title: original_title,
content: original_content,
@@ -104,6 +131,7 @@ RSpec.describe 'Updating a Snippet' do
it_behaves_like 'graphql update actions'
it_behaves_like 'when the snippet is not found'
+ it_behaves_like 'snippet edit usage data counters'
end
describe 'ProjectSnippet' do
@@ -111,6 +139,7 @@ RSpec.describe 'Updating a Snippet' do
let(:snippet) do
create(:project_snippet,
:private,
+ :repository,
project: project,
author: create(:user),
file_name: original_file_name,
@@ -145,44 +174,10 @@ RSpec.describe 'Updating a Snippet' do
expect(errors.first['message']).to eq(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
end
end
- end
-
- it_behaves_like 'when the snippet is not found'
- end
-
- context 'when using the files params' do
- let!(:snippet) { create(:personal_snippet, :private, :repository) }
- let(:updated_content) { 'updated_content' }
- let(:updated_file) { 'CHANGELOG' }
- let(:deleted_file) { 'README' }
- let(:mutation_vars) do
- {
- id: snippet_gid,
- blob_actions: [
- { action: :update, filePath: updated_file, content: updated_content },
- { action: :delete, filePath: deleted_file }
- ]
- }
- end
- it 'updates the Snippet' do
- blob_to_update = blob_at(updated_file)
- expect(blob_to_update.data).not_to eq updated_content
-
- blob_to_delete = blob_at(deleted_file)
- expect(blob_to_delete).to be_present
-
- post_graphql_mutation(mutation, current_user: current_user)
-
- blob_to_update = blob_at(updated_file)
- expect(blob_to_update.data).to eq updated_content
-
- blob_to_delete = blob_at(deleted_file)
- expect(blob_to_delete).to be_nil
+ it_behaves_like 'snippet edit usage data counters'
end
- def blob_at(filename)
- snippet.repository.blob_at('HEAD', filename)
- end
+ it_behaves_like 'when the snippet is not found'
end
end
diff --git a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
index ed5552f3e30..705ef28ffd4 100644
--- a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
+++ b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
@@ -59,7 +59,6 @@ RSpec.describe 'Marking all todos done' do
context 'when user is not logged in' do
let(:current_user) { nil }
- 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']
+ it_behaves_like 'a mutation that returns a top-level access error'
end
end
diff --git a/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb b/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb
index 9c4733f6769..8bf8b96aff5 100644
--- a/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb
+++ b/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb
@@ -63,14 +63,11 @@ RSpec.describe 'Marking todos done' do
context 'when todo does not belong to requesting user' do
let(:input) { { id: other_user_todo.to_global_id.to_s } }
- let(:access_error) { 'The resource that you are attempting to access does not exist or you don\'t have permission to perform this action' }
- it 'contains the expected error' do
- post_graphql_mutation(mutation, current_user: current_user)
+ it_behaves_like 'a mutation that returns a top-level access error'
- errors = json_response['errors']
- expect(errors).not_to be_blank
- expect(errors.first['message']).to eq(access_error)
+ it 'results in the correct todo states' do
+ post_graphql_mutation(mutation, current_user: current_user)
expect(todo1.reload.state).to eq('pending')
expect(todo2.reload.state).to eq('done')
@@ -80,7 +77,7 @@ RSpec.describe 'Marking todos done' do
context 'when using an invalid gid' do
let(:input) { { id: 'invalid_gid' } }
- let(:invalid_gid_error) { 'invalid_gid is not a valid GitLab id.' }
+ let(:invalid_gid_error) { 'invalid_gid is not a valid GitLab ID.' }
it 'contains the expected error' do
post_graphql_mutation(mutation, current_user: current_user)
diff --git a/spec/requests/api/graphql/mutations/todos/restore_spec.rb b/spec/requests/api/graphql/mutations/todos/restore_spec.rb
index 6dedde56e13..8451dcdf587 100644
--- a/spec/requests/api/graphql/mutations/todos/restore_spec.rb
+++ b/spec/requests/api/graphql/mutations/todos/restore_spec.rb
@@ -63,14 +63,11 @@ RSpec.describe 'Restoring Todos' do
context 'when todo does not belong to requesting user' do
let(:input) { { id: other_user_todo.to_global_id.to_s } }
- let(:access_error) { 'The resource that you are attempting to access does not exist or you don\'t have permission to perform this action' }
- it 'contains the expected error' do
- post_graphql_mutation(mutation, current_user: current_user)
+ it_behaves_like 'a mutation that returns a top-level access error'
- errors = json_response['errors']
- expect(errors).not_to be_blank
- expect(errors.first['message']).to eq(access_error)
+ it 'results in the correct todo states' do
+ post_graphql_mutation(mutation, current_user: current_user)
expect(todo1.reload.state).to eq('done')
expect(todo2.reload.state).to eq('pending')
@@ -80,7 +77,7 @@ RSpec.describe 'Restoring Todos' do
context 'when using an invalid gid' do
let(:input) { { id: 'invalid_gid' } }
- let(:invalid_gid_error) { 'invalid_gid is not a valid GitLab id.' }
+ let(:invalid_gid_error) { 'invalid_gid is not a valid GitLab ID.' }
it 'contains the expected error' do
post_graphql_mutation(mutation, current_user: current_user)
diff --git a/spec/requests/api/graphql/namespace/projects_spec.rb b/spec/requests/api/graphql/namespace/projects_spec.rb
index 0b634e6b689..03160719389 100644
--- a/spec/requests/api/graphql/namespace/projects_spec.rb
+++ b/spec/requests/api/graphql/namespace/projects_spec.rb
@@ -78,4 +78,43 @@ RSpec.describe 'getting projects' do
it_behaves_like 'a graphql namespace'
end
+
+ describe 'sorting and pagination' do
+ let(:data_path) { [:namespace, :projects] }
+
+ def pagination_query(params, page_info)
+ graphql_query_for(
+ 'namespace',
+ { 'fullPath' => subject.full_path },
+ <<~QUERY
+ projects(includeSubgroups: #{include_subgroups}, search: "#{search}", #{params}) {
+ #{page_info} edges {
+ node {
+ #{all_graphql_fields_for('Project')}
+ }
+ }
+ }
+ QUERY
+ )
+ end
+
+ def pagination_results_data(data)
+ data.map { |project| project.dig('node', 'name') }
+ end
+
+ context 'when sorting by similarity' do
+ let!(:project_1) { create(:project, name: 'Project', path: 'project', namespace: subject) }
+ let!(:project_2) { create(:project, name: 'Test Project', path: 'test-project', namespace: subject) }
+ let!(:project_3) { create(:project, name: 'Test', path: 'test', namespace: subject) }
+ let!(:project_4) { create(:project, name: 'Test Project Other', path: 'other-test-project', namespace: subject) }
+ let(:search) { 'test' }
+ let(:current_user) { user }
+
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'SIMILARITY' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [project_3.name, project_2.name, project_4.name] }
+ end
+ end
+ end
end
diff --git a/spec/requests/api/graphql/project/issue/designs/notes_spec.rb b/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
index ae5c8363d0f..65191e057c7 100644
--- a/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
+++ b/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
@@ -14,8 +14,6 @@ RSpec.describe 'Getting designs related to an issue' do
before do
enable_design_management
-
- note
end
it_behaves_like 'a working graphql query' do
diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb
index 06e613a09bc..5d4276f47ca 100644
--- a/spec/requests/api/graphql/project/issues_spec.rb
+++ b/spec/requests/api/graphql/project/issues_spec.rb
@@ -5,12 +5,13 @@ require 'spec_helper'
RSpec.describe 'getting an issue list for a project' do
include GraphqlHelpers
- let(:project) { create(:project, :repository, :public) }
- let(:current_user) { create(:user) }
let(:issues_data) { graphql_data['project']['issues']['edges'] }
- let!(:issues) do
+
+ let_it_be(:project) { create(:project, :repository, :public) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:issues, reload: true) do
[create(:issue, project: project, discussion_locked: true),
- create(:issue, project: project)]
+ create(:issue, :with_alert, project: project)]
end
let(:fields) do
@@ -85,7 +86,7 @@ RSpec.describe 'getting an issue list for a project' do
end
context 'when there is a confidential issue' do
- let!(:confidential_issue) do
+ let_it_be(:confidential_issue) do
create(:issue, :confidential, project: project)
end
@@ -256,9 +257,140 @@ RSpec.describe 'getting an issue list for a project' do
end
end
- def grab_iids(data = issues_data)
- data.map do |issue|
- issue.dig('node', 'iid').to_i
+ context 'fetching alert management alert' do
+ let(:fields) do
+ <<~QUERY
+ edges {
+ node {
+ iid
+ alertManagementAlert {
+ title
+ }
+ }
+ }
+ QUERY
+ end
+
+ # Alerts need to have developer permission and above
+ before do
+ project.add_developer(current_user)
+ end
+
+ it 'avoids N+1 queries' do
+ control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
+
+ create(:alert_management_alert, :with_issue, project: project )
+
+ expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control)
+ end
+
+ it 'returns the alert data' do
+ post_graphql(query, current_user: current_user)
+
+ alert_titles = issues_data.map { |issue| issue.dig('node', 'alertManagementAlert', 'title') }
+ expected_titles = issues.map { |issue| issue.alert_management_alert&.title }
+
+ expect(alert_titles).to contain_exactly(*expected_titles)
+ end
+ end
+
+ context 'fetching labels' do
+ let(:fields) do
+ <<~QUERY
+ edges {
+ node {
+ id
+ labels {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ before do
+ issues.each do |issue|
+ # create a label for each issue we have to properly test N+1
+ label = create(:label, project: project)
+ issue.update!(labels: [label])
+ end
+ end
+
+ def response_label_ids(response_data)
+ response_data.map do |edge|
+ edge['node']['labels']['nodes'].map { |u| u['id'] }
+ end.flatten
+ end
+
+ def labels_as_global_ids(issues)
+ issues.map(&:labels).flatten.map(&:to_global_id).map(&:to_s)
+ end
+
+ it 'avoids N+1 queries', :aggregate_failures do
+ control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
+ expect(issues_data.count).to eq(2)
+ expect(response_label_ids(issues_data)).to match_array(labels_as_global_ids(issues))
+
+ new_issues = issues + [create(:issue, project: project, labels: [create(:label, project: project)])]
+
+ expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control)
+ # graphql_data is memoized (see spec/support/helpers/graphql_helpers.rb)
+ # so we have to parse the body ourselves the second time
+ issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['edges']
+ expect(issues_data.count).to eq(3)
+ expect(response_label_ids(issues_data)).to match_array(labels_as_global_ids(new_issues))
+ end
+ end
+
+ context 'fetching assignees' do
+ let(:fields) do
+ <<~QUERY
+ edges {
+ node {
+ id
+ assignees {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ before do
+ issues.each do |issue|
+ # create an assignee for each issue we have to properly test N+1
+ assignee = create(:user)
+ issue.update!(assignees: [assignee])
+ end
+ end
+
+ def response_assignee_ids(response_data)
+ response_data.map do |edge|
+ edge['node']['assignees']['nodes'].map { |node| node['id'] }
+ end.flatten
+ end
+
+ def assignees_as_global_ids(issues)
+ issues.map(&:assignees).flatten.map(&:to_global_id).map(&:to_s)
+ end
+
+ it 'avoids N+1 queries', :aggregate_failures do
+ control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
+ expect(issues_data.count).to eq(2)
+ expect(response_assignee_ids(issues_data)).to match_array(assignees_as_global_ids(issues))
+
+ new_issues = issues + [create(:issue, project: project, assignees: [create(:user)])]
+
+ expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control)
+ # graphql_data is memoized (see spec/support/helpers/graphql_helpers.rb)
+ # so we have to parse the body ourselves the second time
+ issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['edges']
+ expect(issues_data.count).to eq(3)
+ expect(response_assignee_ids(issues_data)).to match_array(assignees_as_global_ids(new_issues))
end
end
end
diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb
index c39358a2db1..fae52fe814d 100644
--- a/spec/requests/api/graphql/project/merge_request_spec.rb
+++ b/spec/requests/api/graphql/project/merge_request_spec.rb
@@ -124,7 +124,8 @@ RSpec.describe 'getting merge request information nested in a project' do
'removeSourceBranch' => false,
'cherryPickOnCurrentMergeRequest' => false,
'revertOnCurrentMergeRequest' => false,
- 'updateMergeRequest' => false
+ 'updateMergeRequest' => false,
+ 'canMerge' => false
}
post_graphql(query, current_user: current_user)
diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb
index bb63a5994b0..22b003501a1 100644
--- a/spec/requests/api/graphql/project/merge_requests_spec.rb
+++ b/spec/requests/api/graphql/project/merge_requests_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe 'getting merge request listings nested in a project' do
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:current_user) { create(:user) }
- let_it_be(:label) { create(:label) }
+ let_it_be(:label) { create(:label, project: project) }
let_it_be(:merge_request_a) { create(:labeled_merge_request, :unique_branches, source_project: project, labels: [label]) }
let_it_be(:merge_request_b) { create(:merge_request, :closed, :unique_branches, source_project: project) }
let_it_be(:merge_request_c) { create(:labeled_merge_request, :closed, :unique_branches, source_project: project, labels: [label]) }
@@ -210,4 +210,48 @@ RSpec.describe 'getting merge request listings nested in a project' do
include_examples 'N+1 query check'
end
end
+ describe 'sorting and pagination' do
+ let(:data_path) { [:project, :mergeRequests] }
+
+ def pagination_query(params, page_info)
+ graphql_query_for(
+ :project,
+ { full_path: project.full_path },
+ <<~QUERY
+ mergeRequests(#{params}) {
+ #{page_info} edges {
+ node {
+ id
+ }
+ }
+ }
+ QUERY
+ )
+ end
+
+ def pagination_results_data(data)
+ data.map { |project| project.dig('node', 'id') }
+ end
+
+ context 'when sorting by merged_at DESC' do
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'MERGED_AT_DESC' }
+ let(:first_param) { 2 }
+
+ let(:expected_results) do
+ [
+ merge_request_b,
+ merge_request_c,
+ merge_request_d,
+ merge_request_a
+ ].map(&:to_gid).map(&:to_s)
+ end
+
+ before do
+ merge_request_c.metrics.update!(merged_at: 5.days.ago)
+ merge_request_b.metrics.update!(merged_at: 1.day.ago)
+ end
+ 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 f9c19d9747d..8fce29d0dc6 100644
--- a/spec/requests/api/graphql/project/release_spec.rb
+++ b/spec/requests/api/graphql/project/release_spec.rb
@@ -10,6 +10,8 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
let_it_be(:guest) { create(:user) }
let_it_be(:reporter) { create(:user) }
let_it_be(:stranger) { create(:user) }
+ let_it_be(:link_filepath) { '/direct/asset/link/path' }
+ let_it_be(:released_at) { Time.now - 1.day }
let(:params_for_issues_and_mrs) { { scope: 'all', state: 'opened', release_tag: release.tag } }
let(:post_query) { post_graphql(query, current_user: current_user) }
@@ -38,6 +40,7 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
name
createdAt
releasedAt
+ upcomingRelease
})
end
@@ -53,7 +56,8 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
'descriptionHtml' => release.description_html,
'name' => release.name,
'createdAt' => release.created_at.iso8601,
- 'releasedAt' => release.released_at.iso8601
+ 'releasedAt' => release.released_at.iso8601,
+ 'upcomingRelease' => false
})
end
end
@@ -127,7 +131,7 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
let(:release_fields) do
query_graphql_field(:assets, nil,
- query_graphql_field(:links, nil, 'nodes { id name url external }'))
+ query_graphql_field(:links, nil, 'nodes { id name url external, directAssetUrl }'))
end
it 'finds all release links' do
@@ -138,7 +142,8 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
'id' => global_id_of(link),
'name' => link.name,
'url' => link.url,
- 'external' => link.external?
+ 'external' => link.external?,
+ 'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << link.filepath : link.url
}
end
@@ -268,9 +273,9 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
let_it_be(:project) { create(:project, :repository, :private) }
let_it_be(:milestone_1) { create(:milestone, project: project) }
let_it_be(:milestone_2) { create(:milestone, project: project) }
- let_it_be(:release) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2]) }
+ let_it_be(:release) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2], released_at: released_at) }
let_it_be(:release_link_1) { create(:release_link, release: release) }
- let_it_be(:release_link_2) { create(:release_link, release: release) }
+ let_it_be(:release_link_2) { create(:release_link, release: release, filepath: link_filepath) }
before_all do
project.add_developer(developer)
@@ -309,9 +314,9 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:milestone_1) { create(:milestone, project: project) }
let_it_be(:milestone_2) { create(:milestone, project: project) }
- let_it_be(:release) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2]) }
+ let_it_be(:release) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2], released_at: released_at) }
let_it_be(:release_link_1) { create(:release_link, release: release) }
- let_it_be(:release_link_2) { create(:release_link, release: release) }
+ let_it_be(:release_link_2) { create(:release_link, release: release, filepath: link_filepath) }
before_all do
project.add_developer(developer)
@@ -371,4 +376,45 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
it_behaves_like 'no access to the release field'
end
end
+
+ describe 'upcoming release' do
+ let(:path) { path_prefix }
+ let(:project) { create(:project, :repository, :private) }
+ let(:release) { create(:release, :with_evidence, project: project, released_at: released_at) }
+ let(:current_user) { developer }
+
+ let(:release_fields) do
+ query_graphql_field(%{
+ releasedAt
+ upcomingRelease
+ })
+ end
+
+ before do
+ project.add_developer(developer)
+ post_query
+ end
+
+ context 'future release' do
+ let(:released_at) { Time.now + 1.day }
+
+ it 'finds all release data' do
+ expect(data).to eq({
+ 'releasedAt' => release.released_at.iso8601,
+ 'upcomingRelease' => true
+ })
+ end
+ end
+
+ context 'past release' do
+ let(:released_at) { Time.now - 1.day }
+
+ it 'finds all release data' do
+ expect(data).to eq({
+ 'releasedAt' => release.released_at.iso8601,
+ 'upcomingRelease' => false
+ })
+ end
+ end
+ end
end
diff --git a/spec/requests/api/graphql/project/releases_spec.rb b/spec/requests/api/graphql/project/releases_spec.rb
index 7e418bbaa5b..7c57c0e9177 100644
--- a/spec/requests/api/graphql/project/releases_spec.rb
+++ b/spec/requests/api/graphql/project/releases_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe 'Query.project(fullPath).releases()' do
graphql_query_for(:project, { fullPath: project.full_path },
%{
releases {
+ count
nodes {
tagName
tagPath
@@ -53,6 +54,20 @@ RSpec.describe 'Query.project(fullPath).releases()' do
stub_default_url_options(host: 'www.example.com')
end
+ shared_examples 'correct total count' do
+ let(:data) { graphql_data.dig('project', 'releases') }
+
+ before do
+ create_list(:release, 2, project: project)
+
+ post_query
+ end
+
+ it 'returns the total count' do
+ expect(data['count']).to eq(project.releases.count)
+ end
+ end
+
shared_examples 'full access to all repository-related fields' do
describe 'repository-related fields' do
before do
@@ -92,6 +107,8 @@ RSpec.describe 'Query.project(fullPath).releases()' do
)
end
end
+
+ it_behaves_like 'correct total count'
end
shared_examples 'no access to any repository-related fields' do
@@ -119,6 +136,8 @@ RSpec.describe 'Query.project(fullPath).releases()' do
)
end
end
+
+ it_behaves_like 'correct total count'
end
# editUrl is tested separately becuase its permissions
diff --git a/spec/requests/api/graphql/project_query_spec.rb b/spec/requests/api/graphql/project_query_spec.rb
index c6049e098be..4b8ffb0675c 100644
--- a/spec/requests/api/graphql/project_query_spec.rb
+++ b/spec/requests/api/graphql/project_query_spec.rb
@@ -5,7 +5,8 @@ require 'spec_helper'
RSpec.describe 'getting project information' do
include GraphqlHelpers
- let(:project) { create(:project, :repository) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :repository, group: group) }
let(:current_user) { create(:user) }
let(:query) do
@@ -60,6 +61,51 @@ RSpec.describe 'getting project information' do
expect(graphql_data['project']['pipelines']['edges'].size).to eq(1)
end
end
+
+ it 'includes inherited members in project_members' do
+ group_member = create(:group_member, group: group)
+ project_member = create(:project_member, project: project)
+ member_query = <<~GQL
+ query {
+ project(fullPath: "#{project.full_path}") {
+ projectMembers {
+ nodes {
+ id
+ user {
+ username
+ }
+ ... on ProjectMember {
+ project {
+ id
+ }
+ }
+ ... on GroupMember {
+ group {
+ id
+ }
+ }
+ }
+ }
+ }
+ }
+ GQL
+
+ post_graphql(member_query, current_user: current_user)
+
+ member_ids = graphql_data.dig('project', 'projectMembers', 'nodes')
+ expect(member_ids).to include(
+ a_hash_including(
+ 'id' => group_member.to_global_id.to_s,
+ 'group' => { 'id' => group.to_global_id.to_s }
+ )
+ )
+ expect(member_ids).to include(
+ a_hash_including(
+ 'id' => project_member.to_global_id.to_s,
+ 'project' => { 'id' => project.to_global_id.to_s }
+ )
+ )
+ end
end
describe 'performance' do
diff --git a/spec/requests/api/graphql/user/starred_projects_query_spec.rb b/spec/requests/api/graphql/user/starred_projects_query_spec.rb
new file mode 100644
index 00000000000..8a1bd3d172f
--- /dev/null
+++ b/spec/requests/api/graphql/user/starred_projects_query_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Getting starredProjects of the user' do
+ include GraphqlHelpers
+
+ let(:query) do
+ graphql_query_for(:user, user_params, user_fields)
+ end
+
+ let(:user_params) { { username: user.username } }
+
+ let_it_be(:project_a) { create(:project, :public) }
+ let_it_be(:project_b) { create(:project, :private) }
+ let_it_be(:project_c) { create(:project, :private) }
+ let_it_be(:user, reload: true) { create(:user) }
+
+ let(:user_fields) { 'starredProjects { nodes { id } }' }
+ let(:starred_projects) { graphql_data_at(:user, :starred_projects, :nodes) }
+
+ before do
+ project_b.add_reporter(user)
+ project_c.add_reporter(user)
+
+ user.toggle_star(project_a)
+ user.toggle_star(project_b)
+ user.toggle_star(project_c)
+
+ post_graphql(query)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'found only public project' do
+ expect(starred_projects).to contain_exactly(
+ a_hash_including('id' => global_id_of(project_a))
+ )
+ end
+
+ context 'the current user is the user' do
+ let(:current_user) { user }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'found all projects' do
+ expect(starred_projects).to contain_exactly(
+ a_hash_including('id' => global_id_of(project_a)),
+ a_hash_including('id' => global_id_of(project_b)),
+ a_hash_including('id' => global_id_of(project_c))
+ )
+ end
+ end
+
+ context 'the current user is a member of a private project the user starred' do
+ let_it_be(:other_user) { create(:user) }
+
+ before do
+ project_b.add_reporter(other_user)
+
+ post_graphql(query, current_user: other_user)
+ end
+
+ it 'finds public and member projects' do
+ expect(starred_projects).to contain_exactly(
+ a_hash_including('id' => global_id_of(project_a)),
+ a_hash_including('id' => global_id_of(project_b))
+ )
+ end
+ end
+end
diff --git a/spec/requests/api/group_packages_spec.rb b/spec/requests/api/group_packages_spec.rb
index e02f6099637..f67cafbd8f5 100644
--- a/spec/requests/api/group_packages_spec.rb
+++ b/spec/requests/api/group_packages_spec.rb
@@ -141,5 +141,7 @@ RSpec.describe API::GroupPackages do
it_behaves_like 'returning response status', :bad_request
end
+
+ it_behaves_like 'does not cause n^2 queries'
end
end
diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb
index 1fa705423d2..9c0ea14e3e3 100644
--- a/spec/requests/api/helpers_spec.rb
+++ b/spec/requests/api/helpers_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe API::Helpers do
end
def error!(message, status, header)
- raise Exception.new("#{status} - #{message}")
+ raise StandardError.new("#{status} - #{message}")
end
def set_param(key, value)
diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb
index 873189af397..4a0a7c81781 100644
--- a/spec/requests/api/internal/base_spec.rb
+++ b/spec/requests/api/internal/base_spec.rb
@@ -415,7 +415,7 @@ RSpec.describe API::Internal::Base do
let(:env) { {} }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
before do
@@ -1179,7 +1179,7 @@ RSpec.describe API::Internal::Base do
let(:gl_repository) { "snippet-#{personal_snippet.id}" }
it 'does not try to notify that project moved' do
- allow(Gitlab::GlRepository).to receive(:parse).and_return([personal_snippet, nil, Gitlab::GlRepository::PROJECT])
+ allow(Gitlab::GlRepository).to receive(:parse).and_return([personal_snippet, nil, Gitlab::GlRepository::SNIPPET])
expect(Gitlab::Checks::ProjectMoved).not_to receive(:fetch_message)
diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb
index 555ca441fe7..f669483b5a4 100644
--- a/spec/requests/api/internal/kubernetes_spec.rb
+++ b/spec/requests/api/internal/kubernetes_spec.rb
@@ -3,24 +3,97 @@
require 'spec_helper'
RSpec.describe API::Internal::Kubernetes do
- describe "GET /internal/kubernetes/agent_info" do
+ let(:jwt_auth_headers) do
+ jwt_token = JWT.encode({ 'iss' => Gitlab::Kas::JWT_ISSUER }, Gitlab::Kas.secret, 'HS256')
+
+ { Gitlab::Kas::INTERNAL_API_REQUEST_HEADER => jwt_token }
+ end
+
+ let(:jwt_secret) { SecureRandom.random_bytes(Gitlab::Kas::SECRET_LENGTH) }
+
+ before do
+ allow(Gitlab::Kas).to receive(:secret).and_return(jwt_secret)
+ end
+
+ shared_examples 'authorization' do
+ context 'not authenticated' do
+ it 'returns 401' do
+ send_request(headers: { Gitlab::Kas::INTERNAL_API_REQUEST_HEADER => '' })
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
context 'kubernetes_agent_internal_api feature flag disabled' do
before do
stub_feature_flags(kubernetes_agent_internal_api: false)
end
it 'returns 404' do
- get api('/internal/kubernetes/agent_info')
+ send_request
expect(response).to have_gitlab_http_status(:not_found)
end
end
+ end
+ shared_examples 'agent authentication' do
it 'returns 403 if Authorization header not sent' do
- get api('/internal/kubernetes/agent_info')
+ send_request
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'returns 403 if Authorization is for non-existent agent' do
+ send_request(headers: { 'Authorization' => 'Bearer NONEXISTENT' })
expect(response).to have_gitlab_http_status(:forbidden)
end
+ end
+
+ describe 'POST /internal/kubernetes/usage_metrics' 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'
+
+ context 'is authenticated for an agent' do
+ let!(:agent_token) { create(:cluster_agent_token) }
+
+ it 'returns no_content for valid gitops_sync_count' do
+ send_request(params: { gitops_sync_count: 10 }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+
+ it 'returns no_content 0 gitops_sync_count' do
+ send_request(params: { gitops_sync_count: 0 }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+
+ it 'returns 400 for non number' do
+ send_request(params: { gitops_sync_count: 'string' }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 400 for negative number' do
+ send_request(params: { gitops_sync_count: '-1' }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+
+ describe "GET /internal/kubernetes/agent_info" do
+ def send_request(headers: {}, params: {})
+ get api('/internal/kubernetes/agent_info'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
+ end
+
+ include_examples 'authorization'
+ include_examples 'agent authentication'
context 'an agent is found' do
let!(:agent_token) { create(:cluster_agent_token) }
@@ -29,7 +102,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:project) { agent.project }
it 'returns expected data', :aggregate_failures do
- get api('/internal/kubernetes/agent_info'), headers: { 'Authorization' => "Bearer #{agent_token.token}" }
+ send_request(headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:success)
@@ -53,42 +126,15 @@ RSpec.describe API::Internal::Kubernetes do
)
end
end
-
- context 'no such agent exists' do
- it 'returns 404' do
- get api('/internal/kubernetes/agent_info'), headers: { 'Authorization' => 'Bearer ABCD' }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
end
describe 'GET /internal/kubernetes/project_info' do
- context 'kubernetes_agent_internal_api feature flag disabled' do
- before do
- stub_feature_flags(kubernetes_agent_internal_api: false)
- end
-
- it 'returns 404' do
- get api('/internal/kubernetes/project_info')
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ def send_request(headers: {}, params: {})
+ get api('/internal/kubernetes/project_info'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
end
- it 'returns 403 if Authorization header not sent' do
- get api('/internal/kubernetes/project_info')
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
-
- context 'no such agent exists' do
- it 'returns 404' do
- get api('/internal/kubernetes/project_info'), headers: { 'Authorization' => 'Bearer ABCD' }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
+ include_examples 'authorization'
+ include_examples 'agent authentication'
context 'an agent is found' do
let!(:agent_token) { create(:cluster_agent_token) }
@@ -99,7 +145,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:project) { create(:project, :public) }
it 'returns expected data', :aggregate_failures do
- get api('/internal/kubernetes/project_info'), params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }
+ send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:success)
@@ -126,7 +172,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:project) { create(:project, :private) }
it 'returns 404' do
- get api('/internal/kubernetes/project_info'), params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }
+ send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -136,7 +182,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:project) { create(:project, :internal) }
it 'returns 404' do
- get api('/internal/kubernetes/project_info'), params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }
+ send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -144,7 +190,7 @@ RSpec.describe API::Internal::Kubernetes do
context 'project does not exist' do
it 'returns 404' do
- get api('/internal/kubernetes/project_info'), params: { id: 0 }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }
+ send_request(params: { id: 0 }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/requests/api/issue_links_spec.rb b/spec/requests/api/issue_links_spec.rb
new file mode 100644
index 00000000000..a4243766111
--- /dev/null
+++ b/spec/requests/api/issue_links_spec.rb
@@ -0,0 +1,207 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::IssueLinks do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ before do
+ project.add_guest(user)
+ end
+
+ describe 'GET /links' do
+ context 'when unauthenticated' do
+ it 'returns 401' do
+ get api("/projects/#{project.id}/issues/#{issue.iid}/links")
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated' do
+ it 'returns related issues' do
+ target_issue = create(:issue, project: project)
+ create(:issue_link, source: issue, target: target_issue)
+
+ get api("/projects/#{project.id}/issues/#{issue.iid}/links", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(response).to match_response_schema('public_api/v4/issue_links')
+ end
+ end
+ end
+
+ describe 'POST /links' do
+ context 'when unauthenticated' do
+ it 'returns 401' do
+ target_issue = create(:issue)
+
+ post api("/projects/#{project.id}/issues/#{issue.iid}/links"),
+ params: { target_project_id: target_issue.project.id, target_issue_iid: target_issue.iid }
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated' do
+ context 'given target project not found' do
+ it 'returns 404' do
+ target_issue = create(:issue)
+
+ post api("/projects/#{project.id}/issues/#{issue.iid}/links", user),
+ params: { target_project_id: -1, target_issue_iid: target_issue.iid }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Project Not Found')
+ end
+ end
+
+ context 'given target issue not found' do
+ it 'returns 404' do
+ target_project = create(:project, :public)
+
+ post api("/projects/#{project.id}/issues/#{issue.iid}/links", user),
+ params: { target_project_id: target_project.id, target_issue_iid: non_existing_record_iid }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Not found')
+ end
+ end
+
+ context 'when user does not have write access to given issue' do
+ it 'returns 404' do
+ unauthorized_project = create(:project)
+ target_issue = create(:issue, project: unauthorized_project)
+ unauthorized_project.add_guest(user)
+
+ 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 Issue found for given params')
+ end
+ end
+
+ context 'when trying to relate to a confidential issue' do
+ it 'returns 404' do
+ project = create(:project, :public)
+ target_issue = create(:issue, :confidential, project: project)
+
+ post api("/projects/#{project.id}/issues/#{issue.iid}/links", user),
+ params: { target_project_id: project.id, target_issue_iid: target_issue.iid }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Not found')
+ end
+ end
+
+ context 'when trying to relate to a private project issue' do
+ it 'returns 404' do
+ project = create(:project, :private)
+ target_issue = create(:issue, project: project)
+
+ post api("/projects/#{project.id}/issues/#{issue.iid}/links", user),
+ params: { target_project_id: project.id, target_issue_iid: target_issue.iid }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Project Not Found')
+ end
+ end
+
+ context 'when user has ability to create an issue link' do
+ let_it_be(:target_issue) { create(:issue, project: project) }
+
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'returns 201 status and contains the expected link response' do
+ post api("/projects/#{project.id}/issues/#{issue.iid}/links", user),
+ params: { target_project_id: project.id, target_issue_iid: target_issue.iid, link_type: 'relates_to' }
+
+ expect_link_response(link_type: 'relates_to')
+ end
+
+ it 'returns 201 when sending full path of target project' do
+ post api("/projects/#{project.id}/issues/#{issue.iid}/links", user),
+ params: { target_project_id: project.full_path, target_issue_iid: target_issue.iid }
+
+ expect_link_response
+ end
+
+ def expect_link_response(link_type: 'relates_to')
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/issue_link')
+ expect(json_response['link_type']).to eq(link_type)
+ end
+ end
+ end
+ end
+
+ describe 'DELETE /links/:issue_link_id' do
+ context 'when unauthenticated' do
+ it 'returns 401' do
+ issue_link = create(:issue_link)
+
+ delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{issue_link.id}")
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated' do
+ context 'when user does not have write access to given issue link' do
+ it 'returns 404' do
+ unauthorized_project = create(:project)
+ target_issue = create(:issue, project: unauthorized_project)
+ issue_link = create(:issue_link, source: issue, target: target_issue)
+ unauthorized_project.add_guest(user)
+
+ delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{issue_link.id}", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('No Issue Link found')
+ end
+ end
+
+ context 'issue link not found' do
+ it 'returns 404' do
+ delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{non_existing_record_id}", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Not found')
+ end
+ end
+
+ context 'when trying to delete a link with a private project issue' do
+ it 'returns 404' do
+ project = create(:project, :private)
+ target_issue = create(:issue, project: project)
+ issue_link = create(:issue_link, source: issue, target: target_issue)
+
+ delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{issue_link.id}", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Project Not Found')
+ end
+ end
+
+ context 'when user has ability to delete the issue link' do
+ it 'returns 200' do
+ target_issue = create(:issue, project: project)
+ issue_link = create(:issue_link, source: issue, target: target_issue)
+ project.add_reporter(user)
+
+ delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{issue_link.id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/issue_link')
+ end
+ end
+ end
+ 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 b0fbf3bf66d..3870c78deee 100644
--- a/spec/requests/api/issues/get_group_issues_spec.rb
+++ b/spec/requests/api/issues/get_group_issues_spec.rb
@@ -402,30 +402,76 @@ RSpec.describe API::Issues do
expect_paginated_array_response([group_closed_issue.id, group_issue.id])
end
- it 'returns an array of labeled group issues' do
- get api(base_url, user), params: { labels: group_label.title }
+ shared_examples 'labels parameter' do
+ it 'returns an array of labeled group issues' do
+ get api(base_url, user), params: { labels: group_label.title }
- expect_paginated_array_response(group_issue.id)
- expect(json_response.first['labels']).to eq([group_label.title])
- end
+ expect_paginated_array_response(group_issue.id)
+ expect(json_response.first['labels']).to eq([group_label.title])
+ end
- it 'returns an array of labeled group issues with labels param as array' do
- get api(base_url, user), params: { labels: [group_label.title] }
+ it 'returns an array of labeled group issues' do
+ get api(base_url, user), params: { labels: group_label.title }
- expect_paginated_array_response(group_issue.id)
- expect(json_response.first['labels']).to eq([group_label.title])
+ expect_paginated_array_response(group_issue.id)
+ expect(json_response.first['labels']).to eq([group_label.title])
+ end
+
+ it 'returns an array of labeled group issues with labels param as array' do
+ get api(base_url, user), params: { labels: [group_label.title] }
+
+ expect_paginated_array_response(group_issue.id)
+ expect(json_response.first['labels']).to eq([group_label.title])
+ end
+
+ it 'returns an array of labeled group issues where all labels match' do
+ get api(base_url, user), params: { labels: "#{group_label.title},foo,bar" }
+
+ expect_paginated_array_response([])
+ end
+
+ it 'returns an array of labeled group issues where all labels match with labels param as array' do
+ get api(base_url, user), params: { labels: [group_label.title, 'foo', 'bar'] }
+
+ expect_paginated_array_response([])
+ end
+
+ context 'with labeled issues' do
+ let(:group_issue2) { create :issue, project: group_project }
+ let(:label_b) { create(:label, title: 'foo', project: group_project) }
+ let(:label_c) { create(:label, title: 'bar', project: group_project) }
+
+ before do
+ create(:label_link, label: group_label, target: group_issue2)
+ create(:label_link, label: label_b, target: group_issue)
+ create(:label_link, label: label_b, target: group_issue2)
+ create(:label_link, label: label_c, target: group_issue)
+
+ get api(base_url, user), params: params
+ end
+
+ let(:issue) { group_issue }
+ let(:issue2) { group_issue2 }
+ let(:label) { group_label }
+
+ it_behaves_like 'labeled issues with labels and label_name params'
+ end
end
- it 'returns an array of labeled group issues where all labels match' do
- get api(base_url, user), params: { labels: "#{group_label.title},foo,bar" }
+ context 'when `optimized_issuable_label_filter` feature flag is off' do
+ before do
+ stub_feature_flags(optimized_issuable_label_filter: false)
+ end
- expect_paginated_array_response([])
+ it_behaves_like 'labels parameter'
end
- it 'returns an array of labeled group issues where all labels match with labels param as array' do
- get api(base_url, user), params: { labels: [group_label.title, 'foo', 'bar'] }
+ context 'when `optimized_issuable_label_filter` feature flag is on' do
+ before do
+ stub_feature_flags(optimized_issuable_label_filter: true)
+ end
- expect_paginated_array_response([])
+ it_behaves_like 'labels parameter'
end
it 'returns issues matching given search string for title' do
@@ -440,27 +486,6 @@ RSpec.describe API::Issues do
expect_paginated_array_response(group_issue.id)
end
- context 'with labeled issues' do
- let(:group_issue2) { create :issue, project: group_project }
- let(:label_b) { create(:label, title: 'foo', project: group_project) }
- let(:label_c) { create(:label, title: 'bar', project: group_project) }
-
- before do
- create(:label_link, label: group_label, target: group_issue2)
- create(:label_link, label: label_b, target: group_issue)
- create(:label_link, label: label_b, target: group_issue2)
- create(:label_link, label: label_c, target: group_issue)
-
- get api(base_url, user), params: params
- end
-
- let(:issue) { group_issue }
- let(:issue2) { group_issue2 }
- let(:label) { group_label }
-
- it_behaves_like 'labeled issues with labels and label_name params'
- end
-
context 'with archived projects' do
let_it_be(:archived_issue) do
create(
diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb
index b638a65d65e..b8cbddd9ed4 100644
--- a/spec/requests/api/issues/issues_spec.rb
+++ b/spec/requests/api/issues/issues_spec.rb
@@ -87,6 +87,46 @@ RSpec.describe API::Issues do
end
end
+ describe 'GET /issues/:id' do
+ context 'when unauthorized' do
+ it 'returns unauthorized' do
+ get api("/issues/#{issue.id}" )
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authorized' do
+ context 'as a normal user' do
+ it 'returns forbidden' do
+ get api("/issues/#{issue.id}", user )
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'as an admin' do
+ context 'when issue exists' do
+ it 'returns the issue' do
+ get api("/issues/#{issue.id}", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.dig('author', 'id')).to eq(issue.author.id)
+ expect(json_response['description']).to eq(issue.description)
+ end
+ end
+
+ context 'when issue does not exist' do
+ it 'returns 404' do
+ get api("/issues/0", admin)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+ end
+
describe 'GET /issues' do
context 'when unauthenticated' do
it 'returns an array of all issues' do
@@ -128,6 +168,11 @@ RSpec.describe API::Issues do
expect_paginated_array_response([issue.id, closed_issue.id])
end
+ it 'responds with a 401 instead of the specified issue' do
+ get api("/issues/#{issue.id}")
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
context 'issues_statistics' do
it 'returns authentication error without any scope' do
get api('/issues_statistics')
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index 77d5a4f26a8..2d57146fbc9 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -5,32 +5,6 @@ require 'spec_helper'
RSpec.describe API::Jobs do
include HttpIOHelpers
- shared_examples 'a job with artifacts and trace' do |result_is_array: true|
- context 'with artifacts and trace' do
- let!(:second_job) { create(:ci_build, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) }
-
- it 'returns artifacts and trace data', :skip_before_request do
- get api(api_endpoint, api_user)
- json_job = result_is_array ? json_response.select { |job| job['id'] == second_job.id }.first : json_response
-
- expect(json_job['artifacts_file']).not_to be_nil
- expect(json_job['artifacts_file']).not_to be_empty
- expect(json_job['artifacts_file']['filename']).to eq(second_job.artifacts_file.filename)
- expect(json_job['artifacts_file']['size']).to eq(second_job.artifacts_file.size)
- expect(json_job['artifacts']).not_to be_nil
- expect(json_job['artifacts']).to be_an Array
- expect(json_job['artifacts'].size).to eq(second_job.job_artifacts.length)
- json_job['artifacts'].each do |artifact|
- expect(artifact).not_to be_nil
- file_type = Ci::JobArtifact.file_types[artifact['file_type']]
- expect(artifact['size']).to eq(second_job.job_artifacts.find_by(file_type: file_type).size)
- expect(artifact['filename']).to eq(second_job.job_artifacts.find_by(file_type: file_type).filename)
- expect(artifact['file_format']).to eq(second_job.job_artifacts.find_by(file_type: file_type).file_format)
- end
- end
- end
- end
-
let_it_be(:project, reload: true) do
create(:project, :repository, public_builds: false)
end
@@ -56,7 +30,7 @@ RSpec.describe API::Jobs do
end
describe 'GET /projects/:id/jobs' do
- let(:query) { Hash.new }
+ let(:query) { {} }
before do |example|
unless example.metadata[:skip_before_request]
@@ -166,295 +140,6 @@ RSpec.describe API::Jobs do
end
end
- describe 'GET /projects/:id/pipelines/:pipeline_id/jobs' do
- let(:query) { Hash.new }
-
- before do |example|
- unless example.metadata[:skip_before_request]
- job
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
- end
- end
-
- context 'authorized user' do
- it 'returns pipeline jobs' 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
- 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)
- expect(json_response.first['artifacts_file']).to be_nil
- expect(json_response.first['artifacts']).to be_an Array
- expect(json_response.first['artifacts']).to be_empty
- end
-
- it_behaves_like 'a job with artifacts and trace' do
- let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" }
- end
-
- it 'returns pipeline data' do
- json_job = json_response.first
-
- expect(json_job['pipeline']).not_to be_empty
- expect(json_job['pipeline']['id']).to eq job.pipeline.id
- expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
- expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
- expect(json_job['pipeline']['status']).to eq job.pipeline.status
- end
-
- context 'filter jobs with one scope element' do
- let(:query) { { 'scope' => 'pending' } }
-
- it do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- end
- end
-
- context 'filter jobs with array of scope elements' do
- let(:query) { { scope: %w(pending running) } }
-
- it do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- end
- end
-
- context 'respond 400 when scope contains invalid state' do
- let(:query) { { scope: %w(unknown running) } }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
-
- context 'jobs in different pipelines' do
- let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
- let!(:job2) { create(:ci_build, pipeline: pipeline2) }
-
- it 'excludes jobs from other pipelines' do
- json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) }
- end
- end
-
- context 'when config source not ci' do
- let(:non_ci_config_source) { ::Ci::PipelineEnums.non_ci_config_source_values.first }
- let(:pipeline) do
- create(:ci_pipeline, config_source: non_ci_config_source, project: project)
- end
-
- it 'returns the specified pipeline' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response[0]['pipeline']['sha']).to eq(pipeline.sha.to_s)
- end
- end
-
- it 'avoids N+1 queries' do
- control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
- end.count
-
- create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline)
-
- expect do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
- end.not_to exceed_all_query_limit(control_count)
- end
- end
-
- context 'unauthorized user' do
- context 'when user is not logged in' do
- let(:api_user) { nil }
-
- it 'does not return jobs' do
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
-
- context 'when user is guest' do
- let(:api_user) { guest }
-
- it 'does not return jobs' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
- end
- end
-
- describe 'GET /projects/:id/pipelines/:pipeline_id/bridges' do
- let!(:bridge) { create(:ci_bridge, pipeline: pipeline) }
- let(:downstream_pipeline) { create(:ci_pipeline) }
-
- let!(:pipeline_source) do
- create(:ci_sources_pipeline,
- source_pipeline: pipeline,
- source_project: project,
- source_job: bridge,
- pipeline: downstream_pipeline,
- project: downstream_pipeline.project)
- end
-
- let(:query) { Hash.new }
-
- before do |example|
- unless example.metadata[:skip_before_request]
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
- end
- end
-
- context 'authorized user' do
- it 'returns pipeline bridges' 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
- 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
- expect(json_response.first['name']).to eq bridge.name
- expect(json_response.first['stage']).to eq bridge.stage
- end
-
- it 'returns pipeline data' do
- json_bridge = json_response.first
-
- expect(json_bridge['pipeline']).not_to be_empty
- expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id
- expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref
- expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha
- expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status
- end
-
- it 'returns downstream pipeline data' do
- json_bridge = json_response.first
-
- expect(json_bridge['downstream_pipeline']).not_to be_empty
- expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id
- expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref
- expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha
- expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status
- end
-
- context 'filter bridges' do
- before do
- create_bridge(pipeline, :pending)
- create_bridge(pipeline, :running)
- end
-
- context 'with one scope element' do
- let(:query) { { 'scope' => 'pending' } }
-
- it :skip_before_request do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- expect(json_response.count).to eq 1
- expect(json_response.first["status"]).to eq "pending"
- end
- end
-
- context 'with array of scope elements' do
- let(:query) { { scope: %w(pending running) } }
-
- it :skip_before_request do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- expect(json_response.count).to eq 2
- json_response.each { |r| expect(%w(pending running).include?(r['status'])).to be true }
- end
- end
- end
-
- context 'respond 400 when scope contains invalid state' do
- let(:query) { { scope: %w(unknown running) } }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
-
- context 'bridges in different pipelines' do
- let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
- let!(:bridge2) { create(:ci_bridge, pipeline: pipeline2) }
-
- it 'excludes bridges from other pipelines' do
- json_response.each { |bridge| expect(bridge['pipeline']['id']).to eq(pipeline.id) }
- end
- end
-
- it 'avoids N+1 queries' do
- control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
- end.count
-
- 3.times { create_bridge(pipeline) }
-
- expect do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
- end.not_to exceed_all_query_limit(control_count)
- end
- end
-
- context 'unauthorized user' do
- context 'when user is not logged in' do
- let(:api_user) { nil }
-
- it 'does not return bridges' do
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
-
- context 'when user is guest' do
- let(:api_user) { guest }
-
- it 'does not return bridges' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when user has no read access for pipeline' do
- before do
- allow(Ability).to receive(:allowed?).and_call_original
- allow(Ability).to receive(:allowed?).with(api_user, :read_pipeline, pipeline).and_return(false)
- end
-
- it 'does not return bridges' do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user)
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when user has no read_build access for project' do
- before do
- allow(Ability).to receive(:allowed?).and_call_original
- allow(Ability).to receive(:allowed?).with(api_user, :read_build, project).and_return(false)
- end
-
- it 'does not return bridges' do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user)
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
- end
-
- def create_bridge(pipeline, status = :created)
- create(:ci_bridge, status: status, pipeline: pipeline).tap do |bridge|
- downstream_pipeline = create(:ci_pipeline)
- create(:ci_sources_pipeline,
- source_pipeline: pipeline,
- source_project: pipeline.project,
- source_job: bridge,
- pipeline: downstream_pipeline,
- project: downstream_pipeline.project)
- end
- end
- end
-
describe 'GET /projects/:id/jobs/:job_id' do
before do |example|
unless example.metadata[:skip_before_request]
diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb
index b74887762a2..0a23aed109b 100644
--- a/spec/requests/api/maven_packages_spec.rb
+++ b/spec/requests/api/maven_packages_spec.rb
@@ -283,7 +283,7 @@ RSpec.describe API::MavenPackages do
context 'internal project' do
before do
- group.group_member(user).destroy
+ group.group_member(user).destroy!
project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
end
@@ -542,6 +542,18 @@ RSpec.describe API::MavenPackages do
context 'when params from workhorse are correct' do
let(:params) { { file: file_upload } }
+ context 'file size is too large' do
+ it 'rejects the request' do
+ allow_next_instance_of(UploadedFile) do |uploaded_file|
+ allow(uploaded_file).to receive(:size).and_return(project.actual_limits.maven_max_file_size + 1)
+ end
+
+ upload_file_with_token(params)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
it 'rejects a malicious request' do
put api("/projects/#{project.id}/packages/maven/com/example/my-app/#{version}/%2e%2e%2f.ssh%2fauthorized_keys"), params: params, headers: headers_with_token
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index 23889912d7a..de52087340c 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -258,8 +258,8 @@ RSpec.describe API::Members do
it 'does not create the member if group level is higher' do
parent = create(:group)
- group.update(parent: parent)
- project.update(group: group)
+ group.update!(parent: parent)
+ project.update!(group: group)
parent.add_developer(stranger)
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
@@ -272,8 +272,8 @@ RSpec.describe API::Members do
it 'creates the member if group level is lower' do
parent = create(:group)
- group.update(parent: parent)
- project.update(group: group)
+ group.update!(parent: parent)
+ project.update!(group: group)
parent.add_developer(stranger)
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
diff --git a/spec/requests/api/merge_request_diffs_spec.rb b/spec/requests/api/merge_request_diffs_spec.rb
index 3f41a7a034d..2e6cbe7bee7 100644
--- a/spec/requests/api/merge_request_diffs_spec.rb
+++ b/spec/requests/api/merge_request_diffs_spec.rb
@@ -8,8 +8,8 @@ RSpec.describe API::MergeRequestDiffs, 'MergeRequestDiffs' do
let!(:project) { merge_request.target_project }
before do
- merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
- merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e')
+ merge_request.merge_request_diffs.create!(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
+ merge_request.merge_request_diffs.create!(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e')
project.add_maintainer(user)
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index d4c05b4b198..2757c56e0fe 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -5,23 +5,19 @@ require "spec_helper"
RSpec.describe API::MergeRequests do
include ProjectForksHelper
- let(:base_time) { Time.now }
+ let_it_be(:base_time) { Time.now }
let_it_be(:user) { create(:user) }
let_it_be(:user2) { create(:user) }
let_it_be(:admin) { create(:user, :admin) }
- let(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace, only_allow_merge_if_pipeline_succeeds: false) }
- let(:milestone) { create(:milestone, title: '1.0.0', project: project) }
- let(:milestone1) { create(:milestone, title: '0.9', project: project) }
- let(:merge_request_context_commit) {create(:merge_request_context_commit, message: 'test')}
- let!(:merge_request) { create(:merge_request, :simple, milestone: milestone1, author: user, assignees: [user], merge_request_context_commits: [merge_request_context_commit], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
- let!(:merge_request_closed) { create(:merge_request, state: "closed", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) }
- let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignees: [user], source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') }
- let!(:merge_request_locked) { create(:merge_request, state: "locked", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Locked test", created_at: base_time + 1.second) }
- let!(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") }
- let!(:note2) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "another comment on a MR") }
+ let_it_be(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace, only_allow_merge_if_pipeline_succeeds: false) }
+
+ let(:milestone1) { create(:milestone, title: '0.9', project: project) }
+ let(:milestone) { create(:milestone, title: '1.0.0', project: project) }
let(:label) { create(:label, title: 'label', color: '#FFAABB', project: project) }
let(:label2) { create(:label, title: 'a-test', color: '#FFFFFF', project: project) }
+ let(:merge_request) { create(:merge_request, :simple, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
+
before do
project.add_reporter(user)
project.add_reporter(user2)
@@ -29,6 +25,16 @@ RSpec.describe API::MergeRequests do
stub_licensed_features(multiple_merge_request_assignees: false)
end
+ shared_context 'with merge requests' do
+ let_it_be(:milestone1) { create(:milestone, title: '0.9', project: project) }
+ let_it_be(:merge_request) { create(:merge_request, :simple, milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
+ let_it_be(:merge_request_closed) { create(:merge_request, state: "closed", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) }
+ let_it_be(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignees: [user], source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') }
+ let_it_be(:merge_request_locked) { create(:merge_request, state: "locked", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Locked test", created_at: base_time + 1.second) }
+ let_it_be(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") }
+ let_it_be(:note2) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "another comment on a MR") }
+ end
+
shared_context 'with labels' do
before do
create(:label_link, label: label, target: merge_request)
@@ -68,6 +74,7 @@ RSpec.describe API::MergeRequests do
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!
@@ -426,14 +433,13 @@ RSpec.describe API::MergeRequests do
end
context 'NOT params' do
- let(:merge_request2) do
+ let!(:merge_request2) do
create(
:merge_request,
:simple,
milestone: milestone,
author: user,
assignees: [user],
- merge_request_context_commits: [merge_request_context_commit],
source_project: project,
target_project: project,
source_branch: 'what',
@@ -442,6 +448,8 @@ RSpec.describe API::MergeRequests do
)
end
+ let!(:merge_request_context_commit) { create(:merge_request_context_commit, merge_request: merge_request2, message: 'test') }
+
before do
create(:label_link, label: label, target: merge_request)
create(:label_link, label: label2, target: merge_request2)
@@ -527,6 +535,8 @@ RSpec.describe API::MergeRequests do
end
describe 'GET /merge_requests' do
+ include_context 'with merge requests'
+
context 'when unauthenticated' do
it 'returns an array of all merge requests' do
get api('/merge_requests', user), params: { scope: 'all' }
@@ -563,9 +573,9 @@ RSpec.describe API::MergeRequests do
end
context 'when authenticated' do
- let!(:project2) { create(:project, :public, namespace: user.namespace) }
- let!(:merge_request2) { create(:merge_request, :simple, author: user, assignees: [user], source_project: project2, target_project: project2) }
- let(:user2) { create(:user) }
+ let_it_be(:project2) { create(:project, :public, :repository, namespace: user.namespace) }
+ let_it_be(:merge_request2) { create(:merge_request, :simple, author: user, assignees: [user], source_project: project2, target_project: project2) }
+ let_it_be(:user2) { create(:user) }
it 'returns an array of all merge requests except unauthorized ones' do
get api('/merge_requests', user), params: { scope: :all }
@@ -778,8 +788,8 @@ RSpec.describe API::MergeRequests do
end
context 'search params' do
- before do
- merge_request.update(title: 'Search title', description: 'Search description')
+ let_it_be(:merge_request) do
+ create(:merge_request, :simple, author: user, source_project: project, target_project: project, title: 'Search title', description: 'Search description')
end
it 'returns merge requests matching given search string for title' do
@@ -818,6 +828,8 @@ RSpec.describe API::MergeRequests do
end
describe "GET /projects/:id/merge_requests" do
+ include_context 'with merge requests'
+
let(:endpoint_path) { "/projects/#{project.id}/merge_requests" }
it_behaves_like 'merge requests list'
@@ -845,7 +857,9 @@ RSpec.describe API::MergeRequests do
end
context 'a project which enforces all discussions to be resolved' do
- let!(:project) { create(:project, :repository, only_allow_merge_if_all_discussions_are_resolved: true) }
+ let_it_be(:project) { create(:project, :repository, only_allow_merge_if_all_discussions_are_resolved: true) }
+
+ include_context 'with merge requests'
it 'avoids N+1 queries' do
control = ActiveRecord::QueryRecorder.new do
@@ -864,6 +878,9 @@ RSpec.describe API::MergeRequests do
describe "GET /groups/:id/merge_requests" do
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, :repository, creator: user, namespace: group, only_allow_merge_if_pipeline_succeeds: false) }
+
+ include_context 'with merge requests'
+
let(:endpoint_path) { "/groups/#{group.id}/merge_requests" }
before do
@@ -877,6 +894,8 @@ RSpec.describe API::MergeRequests do
let_it_be(:subgroup) { create(:group, parent: group) }
let_it_be(:project) { create(:project, :public, :repository, creator: user, namespace: subgroup, only_allow_merge_if_pipeline_succeeds: false) }
+ include_context 'with merge requests'
+
it_behaves_like 'merge requests list'
end
@@ -893,7 +912,7 @@ RSpec.describe API::MergeRequests do
let(:parent_group) { create(:group) }
before do
- group.update(parent_id: parent_group.id)
+ group.update!(parent_id: parent_group.id)
merge_request_merged.reload
end
@@ -936,6 +955,8 @@ RSpec.describe API::MergeRequests do
end
describe "GET /projects/:id/merge_requests/:merge_request_iid" do
+ let(:merge_request) { create(:merge_request, :simple, author: user, assignees: [user], milestone: milestone, source_project: project, source_branch: 'markdown', title: "Test") }
+
it 'matches json schema' do
merge_request = create(:merge_request, :with_test_reports, milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Test", created_at: base_time)
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user)
@@ -1006,7 +1027,7 @@ RSpec.describe API::MergeRequests do
let(:non_member) { create(:user) }
before do
- merge_request.update(author: non_member)
+ merge_request.update!(author: non_member)
end
it 'exposes first_contribution as true' do
@@ -1059,9 +1080,12 @@ RSpec.describe API::MergeRequests do
end
context 'head_pipeline' do
+ let(:project) { create(:project, :repository) }
+ let(:merge_request) { create(:merge_request, :simple, author: user, source_project: project, source_branch: 'markdown', title: "Test") }
+
before do
- merge_request.update(head_pipeline: create(:ci_pipeline))
- merge_request.project.project_feature.update(builds_access_level: 10)
+ merge_request.update!(head_pipeline: create(:ci_pipeline))
+ merge_request.project.project_feature.update!(builds_access_level: 10)
end
context 'when user can read the pipeline' do
@@ -1188,11 +1212,13 @@ RSpec.describe API::MergeRequests do
describe 'GET /projects/:id/merge_requests/:merge_request_iid/participants' do
it_behaves_like 'issuable participants endpoint' do
- let(:entity) { merge_request }
+ let(:entity) { create(:merge_request, :simple, milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
end
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/commits' do
+ include_context 'with merge requests'
+
it 'returns a 200 when merge request is valid' do
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/commits", user)
commit = merge_request.commits.first
@@ -1216,6 +1242,9 @@ RSpec.describe API::MergeRequests do
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/:context_commits' do
+ let_it_be(:merge_request) { create(:merge_request, :simple, author: user, source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
+ let_it_be(:merge_request_context_commit) { create(:merge_request_context_commit, merge_request: merge_request, message: 'test') }
+
it 'returns a 200 when merge request is valid' do
context_commit = merge_request.context_commits.first
@@ -1234,6 +1263,8 @@ RSpec.describe API::MergeRequests do
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/changes' do
+ let_it_be(:merge_request) { create(:merge_request, :simple, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
+
it 'returns the change information of the merge_request' do
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user)
@@ -1254,6 +1285,8 @@ RSpec.describe API::MergeRequests do
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/pipelines' do
+ let_it_be(:merge_request) { create(:merge_request, :simple, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
+
context 'when authorized' do
let!(:pipeline) { create(:ci_empty_pipeline, project: project, user: user, ref: merge_request.source_branch, sha: merge_request.diff_head_sha) }
let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
@@ -1308,16 +1341,15 @@ RSpec.describe API::MergeRequests do
})
end
- let(:project) do
+ let_it_be(:project) do
create(:project, :private, :repository,
creator: user,
namespace: user.namespace,
only_allow_merge_if_pipeline_succeeds: false)
end
- let(:merge_request) do
+ let_it_be(:merge_request) do
create(:merge_request, :with_detached_merge_request_pipeline,
- milestone: milestone1,
author: user,
assignees: [user],
source_project: project,
@@ -1351,7 +1383,7 @@ RSpec.describe API::MergeRequests do
end
context 'when the merge request does not exist' do
- let(:merge_request_iid) { 777 }
+ let(:merge_request_iid) { non_existing_record_id }
it 'responds with a blank 404' do
expect { request }.not_to change(Ci::Pipeline, :count)
@@ -1604,7 +1636,7 @@ RSpec.describe API::MergeRequests do
end.to change { MergeRequest.count }.by(0)
expect(response).to have_gitlab_http_status(:conflict)
- expect(json_response['message']).to eq(["Another open merge request already exists for this source branch: !5"])
+ expect(json_response['message']).to eq(["Another open merge request already exists for this source branch: !1"])
end
end
@@ -1659,7 +1691,7 @@ RSpec.describe API::MergeRequests do
end
it 'returns 403 when target project has disabled merge requests' do
- project.project_feature.update(merge_requests_access_level: 0)
+ project.project_feature.update!(merge_requests_access_level: 0)
post api("/projects/#{forked_project.id}/merge_requests", user2),
params: {
@@ -1778,6 +1810,7 @@ RSpec.describe API::MergeRequests do
before do
create(:merge_request_context_commit, merge_request: merge_request, sha: commit.id)
end
+
it 'returns 400 when the context commit is already created' do
post api("/projects/#{project.id}/merge_requests/#{merge_request_iid}/context_commits", authenticated_user), params: params
expect(response).to have_gitlab_http_status(:bad_request)
@@ -1937,7 +1970,9 @@ RSpec.describe API::MergeRequests do
end
describe "PUT /projects/:id/merge_requests/:merge_request_iid/merge", :clean_gitlab_redis_cache do
- let(:pipeline) { create(:ci_pipeline) }
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
+ let(:merge_request) { create(:merge_request, :simple, author: user, source_project: project, source_branch: 'markdown', title: 'Test') }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
it "returns merge_request in case of success" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user)
@@ -2111,7 +2146,7 @@ RSpec.describe API::MergeRequests do
let(:source_branch) { merge_request.source_branch }
before do
- merge_request.update(merge_params: { 'force_remove_source_branch' => true })
+ merge_request.update!(merge_params: { 'force_remove_source_branch' => true })
end
it 'removes the source branch' do
@@ -2138,7 +2173,7 @@ RSpec.describe API::MergeRequests do
let(:merge_request) { create(:merge_request, :rebased, source_project: project, squash: true) }
before do
- project.update(merge_requests_ff_only_enabled: true)
+ project.update!(merge_requests_ff_only_enabled: true)
end
it "records the squash commit SHA and returns it in the response" do
@@ -2169,9 +2204,7 @@ RSpec.describe API::MergeRequests do
end
context 'when merge-ref is not synced with merge status' do
- before do
- merge_request.update!(merge_status: 'cannot_be_merged')
- end
+ let(:merge_request) { create(:merge_request, :simple, author: user, source_project: project, source_branch: 'markdown', merge_status: 'cannot_be_merged') }
it 'returns 200 if MR can be merged' do
get api(url, user)
@@ -2230,7 +2263,7 @@ RSpec.describe API::MergeRequests do
describe "PUT /projects/:id/merge_requests/:merge_request_iid" do
context 'updates force_remove_source_branch properly' do
it 'sets to false' do
- merge_request.update(merge_params: { 'force_remove_source_branch' => true } )
+ merge_request.update!(merge_params: { 'force_remove_source_branch' => true } )
expect(merge_request.force_remove_source_branch?).to be_truthy
@@ -2242,7 +2275,7 @@ RSpec.describe API::MergeRequests do
end
it 'sets to true' do
- merge_request.update(merge_params: { 'force_remove_source_branch' => false } )
+ merge_request.update!(merge_params: { 'force_remove_source_branch' => false } )
expect(merge_request.force_remove_source_branch?).to be_falsey
@@ -2254,6 +2287,7 @@ RSpec.describe API::MergeRequests do
end
context 'with a merge request across forks' do
+ let(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace, only_allow_merge_if_pipeline_succeeds: false) }
let(:fork_owner) { create(:user) }
let(:source_project) { fork_project(project, fork_owner) }
let(:target_project) { project }
@@ -2320,12 +2354,46 @@ RSpec.describe API::MergeRequests do
expect(json_response['squash']).to be_truthy
end
- it "returns merge_request with renamed target_branch" do
+ it "updates target_branch and returns merge_request" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), params: { target_branch: "wiki" }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['target_branch']).to eq('wiki')
end
+ context "forked projects" do
+ let_it_be(:user2) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+ let!(:forked_project) { fork_project(project, user2, repository: true) }
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: forked_project,
+ target_project: project,
+ source_branch: "fixes")
+ end
+
+ shared_examples "update of allow_collaboration and allow_maintainer_to_push" do |request_value, expected_value|
+ %w[allow_collaboration allow_maintainer_to_push].each do |attr|
+ it "attempts to update #{attr} to #{request_value} and returns #{expected_value} for `allow_collaboration`" do
+ put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user2), params: { attr => request_value }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response["allow_collaboration"]).to eq(expected_value)
+ expect(json_response["allow_maintainer_to_push"]).to eq(expected_value)
+ end
+ end
+ end
+
+ context "when source project is public (i.e. MergeRequest#collaborative_push_possible? == true)" do
+ it_behaves_like "update of allow_collaboration and allow_maintainer_to_push", true, true
+ end
+
+ context "when source project is private (i.e. MergeRequest#collaborative_push_possible? == false)" do
+ let(:project) { create(:project, :private, :repository) }
+
+ it_behaves_like "update of allow_collaboration and allow_maintainer_to_push", true, false
+ end
+ end
+
it "returns merge_request that removes the source branch" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), params: { remove_source_branch: true }
@@ -2717,7 +2785,7 @@ RSpec.describe API::MergeRequests do
end
describe 'Time tracking' do
- let(:issuable) { merge_request }
+ let!(:issuable) { create(:merge_request, :simple, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
include_examples 'time tracking endpoints', 'merge_request'
end
@@ -2726,7 +2794,7 @@ RSpec.describe API::MergeRequests do
merge_request
merge_request.created_at += 1.hour
merge_request.updated_at += 30.minutes
- merge_request.save
+ merge_request.save!
merge_request
end
@@ -2734,7 +2802,7 @@ RSpec.describe API::MergeRequests do
merge_request_closed
merge_request_closed.created_at -= 1.hour
merge_request_closed.updated_at -= 30.minutes
- merge_request_closed.save
+ merge_request_closed.save!
merge_request_closed
end
end
diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb
index ca4ebd3689f..baab72d106f 100644
--- a/spec/requests/api/notes_spec.rb
+++ b/spec/requests/api/notes_spec.rb
@@ -81,7 +81,7 @@ RSpec.describe API::Notes do
context "issue is confidential" do
before do
- ext_issue.update(confidential: true)
+ ext_issue.update!(confidential: true)
end
it "returns 404" do
@@ -183,7 +183,7 @@ RSpec.describe API::Notes do
context "when issue is confidential" do
before do
- issue.update(confidential: true)
+ issue.update!(confidential: true)
end
it "returns 404" do
diff --git a/spec/requests/api/nuget_packages_spec.rb b/spec/requests/api/nuget_packages_spec.rb
index 87c62ec41c6..62f244c433b 100644
--- a/spec/requests/api/nuget_packages_spec.rb
+++ b/spec/requests/api/nuget_packages_spec.rb
@@ -220,6 +220,18 @@ RSpec.describe API::NugetPackages do
it_behaves_like 'rejects nuget access with unknown project id'
it_behaves_like 'rejects nuget access with invalid project id'
+
+ context 'file size above maximum limit' do
+ let(:headers) { basic_auth_header(deploy_token.username, deploy_token.token).merge(workhorse_header) }
+
+ before do
+ allow_next_instance_of(UploadedFile) do |uploaded_file|
+ allow(uploaded_file).to receive(:size).and_return(project.actual_limits.nuget_max_file_size + 1)
+ end
+ end
+
+ it_behaves_like 'returning response status', :bad_request
+ end
end
end
diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb
index f5971054b3c..23d5df873d4 100644
--- a/spec/requests/api/oauth_tokens_spec.rb
+++ b/spec/requests/api/oauth_tokens_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe 'OAuth tokens' do
request_oauth_token(user, client_basic_auth_header(client))
- expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('invalid_grant')
end
end
@@ -62,7 +62,7 @@ RSpec.describe 'OAuth tokens' do
request_oauth_token(user, basic_auth_header(client.uid, 'invalid secret'))
- expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('invalid_client')
end
end
@@ -72,7 +72,7 @@ RSpec.describe 'OAuth tokens' do
shared_examples 'does not create an access token' do
let(:user) { create(:user) }
- it { expect(response).to have_gitlab_http_status(:unauthorized) }
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
end
context 'when user is blocked' do
diff --git a/spec/requests/api/pages/internal_access_spec.rb b/spec/requests/api/pages/internal_access_spec.rb
index c894a2d3ca4..4ac47f17b7e 100644
--- a/spec/requests/api/pages/internal_access_spec.rb
+++ b/spec/requests/api/pages/internal_access_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe "Internal Project Pages Access" do
with_them do
before do
- project.project_feature.update(pages_access_level: pages_access_level)
+ project.project_feature.update!(pages_access_level: pages_access_level)
end
it "correct return value" do
if !with_user.nil?
diff --git a/spec/requests/api/pages/pages_spec.rb b/spec/requests/api/pages/pages_spec.rb
index 53e732928ff..f4c6de00e40 100644
--- a/spec/requests/api/pages/pages_spec.rb
+++ b/spec/requests/api/pages/pages_spec.rb
@@ -39,7 +39,9 @@ RSpec.describe API::Pages do
expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true
expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything)
- delete api("/projects/#{project.id}/pages", admin )
+ Sidekiq::Testing.inline! do
+ delete api("/projects/#{project.id}/pages", admin )
+ end
expect(project.reload.pages_metadatum.deployed?).to be(false)
end
diff --git a/spec/requests/api/pages/private_access_spec.rb b/spec/requests/api/pages/private_access_spec.rb
index ea5db691b14..c1c0e406508 100644
--- a/spec/requests/api/pages/private_access_spec.rb
+++ b/spec/requests/api/pages/private_access_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe "Private Project Pages Access" do
with_them do
before do
- project.project_feature.update(pages_access_level: pages_access_level)
+ project.project_feature.update!(pages_access_level: pages_access_level)
end
it "correct return value" do
if !with_user.nil?
diff --git a/spec/requests/api/pages/public_access_spec.rb b/spec/requests/api/pages/public_access_spec.rb
index ae73cee91d5..c45b3a4c55e 100644
--- a/spec/requests/api/pages/public_access_spec.rb
+++ b/spec/requests/api/pages/public_access_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe "Public Project Pages Access" do
with_them do
before do
- project.project_feature.update(pages_access_level: pages_access_level)
+ project.project_feature.update!(pages_access_level: pages_access_level)
end
it "correct return value" do
if !with_user.nil?
diff --git a/spec/requests/api/project_milestones_spec.rb b/spec/requests/api/project_milestones_spec.rb
index d1e5df66b3f..71535e66353 100644
--- a/spec/requests/api/project_milestones_spec.rb
+++ b/spec/requests/api/project_milestones_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe API::ProjectMilestones do
let(:milestones) { [group_milestone, ancestor_group_milestone, milestone, closed_milestone] }
before_all do
- project.update(namespace: group)
+ project.update!(namespace: group)
end
it_behaves_like 'listing all milestones'
@@ -108,7 +108,7 @@ RSpec.describe API::ProjectMilestones do
let(:group) { create(:group) }
before do
- project.update(namespace: group)
+ project.update!(namespace: group)
end
context 'when user does not have permission to promote milestone' do
@@ -163,7 +163,7 @@ RSpec.describe API::ProjectMilestones do
context 'when project does not belong to group' do
before do
- project.update(namespace: user.namespace)
+ project.update!(namespace: user.namespace)
end
it 'returns 403' do
diff --git a/spec/requests/api/project_packages_spec.rb b/spec/requests/api/project_packages_spec.rb
index 0ece3bff8f9..2f0d0fc87ec 100644
--- a/spec/requests/api/project_packages_spec.rb
+++ b/spec/requests/api/project_packages_spec.rb
@@ -104,6 +104,8 @@ RSpec.describe API::ProjectPackages do
expect(json_response.first['name']).to include(package2.name)
end
end
+
+ it_behaves_like 'does not cause n^2 queries'
end
end
@@ -127,6 +129,22 @@ RSpec.describe API::ProjectPackages do
end
context 'without the need for a license' do
+ context 'with build info' do
+ it 'does not result in additional queries' do
+ control = ActiveRecord::QueryRecorder.new do
+ get api(package_url, user)
+ end
+
+ pipeline = create(:ci_pipeline, user: user)
+ create(:ci_build, user: user, pipeline: pipeline)
+ create(:package_build_info, package: package1, pipeline: pipeline)
+
+ expect do
+ get api(package_url, user)
+ end.not_to exceed_query_limit(control)
+ end
+ end
+
context 'project is public' do
it 'returns 200 and the package information' do
subject
diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb
index 9b876edae24..08c88873078 100644
--- a/spec/requests/api/project_snippets_spec.rb
+++ b/spec/requests/api/project_snippets_spec.rb
@@ -304,56 +304,10 @@ RSpec.describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PUBLIC }
let(:snippet) { create(:project_snippet, :repository, author: admin, visibility_level: visibility_level, project: project) }
- it 'updates snippet' do
- new_content = 'New content'
- new_description = 'New description'
-
- update_snippet(params: { content: new_content, description: new_description, visibility: 'private' })
-
- expect(response).to have_gitlab_http_status(:ok)
- snippet.reload
- expect(snippet.content).to eq(new_content)
- expect(snippet.description).to eq(new_description)
- expect(snippet.visibility).to eq('private')
- end
-
- it 'updates snippet with content parameter' do
- new_content = 'New content'
- new_description = 'New description'
-
- update_snippet(params: { content: new_content, description: new_description })
-
- expect(response).to have_gitlab_http_status(:ok)
- snippet.reload
- expect(snippet.content).to eq(new_content)
- expect(snippet.description).to eq(new_description)
- end
-
- it 'returns 404 for invalid snippet id' do
- update_snippet(snippet_id: non_existing_record_id, params: { title: 'foo' })
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('404 Snippet Not Found')
- end
-
- it 'returns 400 for missing parameters' do
- update_snippet
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
-
- it 'returns 400 if content is blank' do
- update_snippet(params: { content: '' })
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
-
- it 'returns 400 if title is blank' do
- update_snippet(params: { title: '' })
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq 'title is empty'
- end
+ it_behaves_like 'snippet file updates'
+ it_behaves_like 'snippet non-file updates'
+ it_behaves_like 'snippet individual non-file updates'
+ it_behaves_like 'invalid snippet updates'
it_behaves_like 'update with repository actions' do
let(:snippet_without_repo) { create(:project_snippet, author: admin, project: project, visibility_level: visibility_level) }
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 46340f86f69..831b0d6e678 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -49,15 +49,15 @@ end
RSpec.describe API::Projects do
include ProjectForksHelper
- let(:user) { create(:user) }
- let(:user2) { create(:user) }
- let(:user3) { create(:user) }
- let(:admin) { create(:admin) }
- let(:project) { create(:project, :repository, namespace: user.namespace) }
- let(:project2) { create(:project, namespace: user.namespace) }
- let(:project_member) { create(:project_member, :developer, user: user3, project: project) }
- let(:user4) { create(:user, username: 'user.with.dot') }
- let(:project3) do
+ let_it_be(:user) { create(:user) }
+ 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, namespace: user.namespace) }
+ let_it_be(:project2, reload: true) { create(:project, namespace: user.namespace) }
+ let_it_be(:project_member) { create(:project_member, :developer, user: user3, project: project) }
+ let_it_be(:user4) { create(:user, username: 'user.with.dot') }
+ let_it_be(:project3, reload: true) do
create(:project,
:private,
:repository,
@@ -71,14 +71,14 @@ RSpec.describe API::Projects do
snippets_enabled: false)
end
- let(:project_member2) do
+ let_it_be(:project_member2) do
create(:project_member,
user: user4,
project: project3,
access_level: ProjectMember::MAINTAINER)
end
- let(:project4) do
+ let_it_be(:project4, reload: true) do
create(:project,
name: 'third_project',
path: 'third_project',
@@ -86,6 +86,8 @@ RSpec.describe API::Projects do
namespace: user4.namespace)
end
+ let(:user_projects) { [public_project, project, project2, project3] }
+
shared_context 'with language detection' do
let(:ruby) { create(:programming_language, name: 'Ruby') }
let(:javascript) { create(:programming_language, name: 'JavaScript') }
@@ -146,14 +148,7 @@ RSpec.describe API::Projects do
end
end
- let!(:public_project) { create(:project, :public, name: 'public_project') }
-
- before do
- project
- project2
- project3
- project4
- end
+ let_it_be(:public_project) { create(:project, :public, name: 'public_project') }
context 'when unauthenticated' do
it_behaves_like 'projects response' do
@@ -171,7 +166,7 @@ RSpec.describe API::Projects do
it_behaves_like 'projects response' do
let(:filter) { {} }
let(:current_user) { user }
- let(:projects) { [public_project, project, project2, project3] }
+ let(:projects) { user_projects }
end
it_behaves_like 'projects response without N + 1 queries' do
@@ -208,7 +203,7 @@ RSpec.describe API::Projects do
end
it 'does not include projects marked for deletion' do
- project.update(pending_delete: true)
+ project.update!(pending_delete: true)
get api('/projects', user)
@@ -257,7 +252,7 @@ RSpec.describe API::Projects do
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
- statistics = json_response.first['statistics']
+ statistics = json_response.find { |p| p['id'] == project.id }['statistics']
expect(statistics).to be_present
expect(statistics).to include('commit_count', 'storage_size', 'repository_size', 'wiki_size', 'lfs_objects_size', 'job_artifacts_size', 'snippets_size')
end
@@ -386,14 +381,14 @@ RSpec.describe API::Projects do
it_behaves_like 'projects response' do
let(:filter) { { id_after: project2.id } }
let(:current_user) { user }
- let(:projects) { [public_project, project, project2, project3].select { |p| p.id > project2.id } }
+ let(:projects) { user_projects.select { |p| p.id > project2.id } }
end
context 'regression: empty string is ignored' do
it_behaves_like 'projects response' do
let(:filter) { { id_after: '' } }
let(:current_user) { user }
- let(:projects) { [public_project, project, project2, project3] }
+ let(:projects) { user_projects }
end
end
end
@@ -402,14 +397,14 @@ RSpec.describe API::Projects do
it_behaves_like 'projects response' do
let(:filter) { { id_before: project2.id } }
let(:current_user) { user }
- let(:projects) { [public_project, project, project2, project3].select { |p| p.id < project2.id } }
+ let(:projects) { user_projects.select { |p| p.id < project2.id } }
end
context 'regression: empty string is ignored' do
it_behaves_like 'projects response' do
let(:filter) { { id_before: '' } }
let(:current_user) { user }
- let(:projects) { [public_project, project, project2, project3] }
+ let(:projects) { user_projects }
end
end
end
@@ -418,7 +413,7 @@ RSpec.describe API::Projects do
it_behaves_like 'projects response' do
let(:filter) { { id_before: project2.id, id_after: public_project.id } }
let(:current_user) { user }
- let(:projects) { [public_project, project, project2, project3].select { |p| p.id < project2.id && p.id > public_project.id } }
+ let(:projects) { user_projects.select { |p| p.id < project2.id && p.id > public_project.id } }
end
end
@@ -481,7 +476,7 @@ RSpec.describe API::Projects 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.first['id']).to eq(project3.id)
+ expect(json_response.map { |p| p['id'] }).to eq(user_projects.map(&:id).sort.reverse)
end
end
@@ -495,14 +490,32 @@ RSpec.describe API::Projects do
expect(json_response.first['name']).to eq(project4.name)
expect(json_response.first['owner']['username']).to eq(user4.username)
end
+
+ context 'when admin creates a project' do
+ before do
+ group = create(:group)
+ project_create_opts = {
+ name: 'GitLab',
+ namespace_id: group.id
+ }
+
+ Projects::CreateService.new(admin, project_create_opts).execute
+ end
+
+ it 'does not list as owned project for admin' do
+ get api('/projects', admin), params: { owned: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_empty
+ end
+ end
end
context 'and with starred=true' do
let(:public_project) { create(:project, :public) }
before do
- project_member
- user3.update(starred_projects: [project, project2, project3, public_project])
+ user3.update!(starred_projects: [project, project2, project3, public_project])
end
it 'returns the starred projects viewable by the user' do
@@ -523,7 +536,7 @@ RSpec.describe API::Projects do
let!(:project9) { create(:project, :public, path: 'gitlab9') }
before do
- user.update(starred_projects: [project5, project7, project8, project9])
+ user.update!(starred_projects: [project5, project7, project8, project9])
end
context 'including owned filter' do
@@ -642,7 +655,6 @@ RSpec.describe API::Projects do
context 'non-admin user' do
let(:current_user) { user }
- let(:projects) { [public_project, project, project2, project3] }
it 'returns projects ordered normally' do
get api('/projects', current_user), params: { order_by: order_by }
@@ -650,7 +662,7 @@ RSpec.describe API::Projects 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 { |project| project['id'] }).to eq(projects.map(&:id).reverse)
+ expect(json_response.map { |project| project['id'] }).to eq(user_projects.map(&:id).sort.reverse)
end
end
end
@@ -686,7 +698,8 @@ RSpec.describe API::Projects do
context 'with keyset pagination' do
let(:current_user) { user }
- let(:projects) { [public_project, project, project2, project3] }
+ let(:first_project_id) { user_projects.map(&:id).min }
+ let(:last_project_id) { user_projects.map(&:id).max }
context 'headers and records' do
let(:params) { { pagination: 'keyset', order_by: :id, sort: :asc, per_page: 1 } }
@@ -696,11 +709,11 @@ RSpec.describe API::Projects do
expect(response.header).to include('Links')
expect(response.header['Links']).to include('pagination=keyset')
- expect(response.header['Links']).to include("id_after=#{public_project.id}")
+ expect(response.header['Links']).to include("id_after=#{first_project_id}")
expect(response.header).to include('Link')
expect(response.header['Link']).to include('pagination=keyset')
- expect(response.header['Link']).to include("id_after=#{public_project.id}")
+ expect(response.header['Link']).to include("id_after=#{first_project_id}")
end
it 'contains only the first project with per_page = 1' do
@@ -708,7 +721,7 @@ RSpec.describe API::Projects do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
- expect(json_response.map { |p| p['id'] }).to contain_exactly(public_project.id)
+ expect(json_response.map { |p| p['id'] }).to contain_exactly(first_project_id)
end
it 'still includes a link if the end has reached and there is no more data after this page' do
@@ -752,11 +765,11 @@ RSpec.describe API::Projects do
expect(response.header).to include('Links')
expect(response.header['Links']).to include('pagination=keyset')
- expect(response.header['Links']).to include("id_before=#{project3.id}")
+ expect(response.header['Links']).to include("id_before=#{last_project_id}")
expect(response.header).to include('Link')
expect(response.header['Link']).to include('pagination=keyset')
- expect(response.header['Link']).to include("id_before=#{project3.id}")
+ expect(response.header['Link']).to include("id_before=#{last_project_id}")
end
it 'contains only the last project with per_page = 1' do
@@ -764,7 +777,7 @@ RSpec.describe API::Projects do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
- expect(json_response.map { |p| p['id'] }).to contain_exactly(project3.id)
+ expect(json_response.map { |p| p['id'] }).to contain_exactly(last_project_id)
end
end
@@ -793,7 +806,7 @@ RSpec.describe API::Projects do
ids += Gitlab::Json.parse(response.body).map { |p| p['id'] }
end
- expect(ids).to contain_exactly(*projects.map(&:id))
+ expect(ids).to contain_exactly(*user_projects.map(&:id))
end
end
end
@@ -814,7 +827,7 @@ RSpec.describe API::Projects do
.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
- project = Project.first
+ project = Project.last
expect(project.name).to eq('Foo Project')
expect(project.path).to eq('foo-project')
@@ -825,7 +838,7 @@ RSpec.describe API::Projects do
.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
- project = Project.first
+ project = Project.last
expect(project.name).to eq('foo_project')
expect(project.path).to eq('foo_project')
@@ -836,7 +849,7 @@ RSpec.describe API::Projects do
.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
- project = Project.first
+ project = Project.last
expect(project.name).to eq('Foo Project')
expect(project.path).to eq('path-project-Foo')
@@ -1232,7 +1245,7 @@ RSpec.describe API::Projects do
describe 'GET /users/:user_id/starred_projects/' do
before do
- user3.update(starred_projects: [project, project2, project3])
+ user3.update!(starred_projects: [project, project2, project3])
end
it 'returns error when user not found' do
@@ -1745,7 +1758,7 @@ RSpec.describe API::Projects do
end
it 'returns 404 when project is marked for deletion' do
- project.update(pending_delete: true)
+ project.update!(pending_delete: true)
get api("/projects/#{project.id}", user)
@@ -1985,7 +1998,8 @@ RSpec.describe API::Projects do
context 'when authenticated' do
context 'valid request' do
it_behaves_like 'project users response' do
- let(:current_user) { user }
+ let(:project) { project4 }
+ let(:current_user) { user4 }
end
end
@@ -2011,8 +2025,8 @@ RSpec.describe API::Projects do
get api("/projects/#{project.id}/users?skip_users=#{user.id}", user)
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.size).to eq(1)
- expect(json_response[0]['id']).to eq(other_user.id)
+ expect(json_response.size).to eq(2)
+ expect(json_response.map { |m| m['id'] }).not_to include(user.id)
end
end
end
@@ -2260,7 +2274,7 @@ RSpec.describe API::Projects do
end
it "returns a 400 error when sharing is disabled" do
- project.namespace.update(share_with_group_lock: true)
+ 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 }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -2417,7 +2431,7 @@ RSpec.describe API::Projects do
end
it 'updates visibility_level from public to private' do
- project3.update({ visibility_level: Gitlab::VisibilityLevel::PUBLIC })
+ project3.update!({ visibility_level: Gitlab::VisibilityLevel::PUBLIC })
project_param = { visibility: 'private' }
put api("/projects/#{project3.id}", user), params: project_param
@@ -2877,8 +2891,8 @@ RSpec.describe API::Projects do
let(:private_user) { create(:user, private_profile: true) }
before do
- user.update(starred_projects: [public_project])
- private_user.update(starred_projects: [public_project])
+ user.update!(starred_projects: [public_project])
+ private_user.update!(starred_projects: [public_project])
end
it 'returns not_found(404) for not existing project' do
diff --git a/spec/requests/api/pypi_packages_spec.rb b/spec/requests/api/pypi_packages_spec.rb
index e2cfd87b507..e72ac002f6b 100644
--- a/spec/requests/api/pypi_packages_spec.rb
+++ b/spec/requests/api/pypi_packages_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe API::PypiPackages do
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
+ let_it_be(:job) { create(:ci_build, :running, user: user) }
describe 'GET /api/v4/projects/:id/packages/pypi/simple/:package_name' do
let_it_be(:package) { create(:pypi_package, project: project) }
@@ -58,6 +59,8 @@ RSpec.describe API::PypiPackages do
it_behaves_like 'deploy token for package GET requests'
+ it_behaves_like 'job token for package GET requests'
+
it_behaves_like 'rejects PyPI access with unknown project id'
end
@@ -108,6 +111,8 @@ RSpec.describe API::PypiPackages do
it_behaves_like 'deploy token for package uploads'
+ it_behaves_like 'job token for package uploads'
+
it_behaves_like 'rejects PyPI access with unknown project id'
end
@@ -117,7 +122,8 @@ RSpec.describe API::PypiPackages do
let_it_be(:file_name) { 'package.whl' }
let(:url) { "/projects/#{project.id}/packages/pypi" }
let(:headers) { {} }
- let(:base_params) { { requires_python: '>=3.7', version: '1.0.0', name: 'sample-project', sha256_digest: '123' } }
+ let(:requires_python) { '>=3.7' }
+ let(:base_params) { { requires_python: requires_python, version: '1.0.0', name: 'sample-project', sha256_digest: '123' } }
let(:params) { base_params.merge(content: temp_file(file_name)) }
let(:send_rewritten_field) { true }
@@ -169,6 +175,19 @@ RSpec.describe API::PypiPackages do
end
end
+ context 'with required_python too big' do
+ let(:requires_python) { 'x' * 256 }
+ let(:token) { personal_access_token.token }
+ let(:user_headers) { basic_auth_header(user.username, token) }
+ let(:headers) { user_headers.merge(workhorse_header) }
+
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it_behaves_like 'process PyPi api request', :developer, :bad_request, true
+ end
+
context 'with an invalid package' do
let(:token) { personal_access_token.token }
let(:user_headers) { basic_auth_header(user.username, token) }
@@ -184,7 +203,21 @@ RSpec.describe API::PypiPackages do
it_behaves_like 'deploy token for package uploads'
+ it_behaves_like 'job token for package uploads'
+
it_behaves_like 'rejects PyPI access with unknown project id'
+
+ context 'file size above maximum limit' do
+ let(:headers) { basic_auth_header(deploy_token.username, deploy_token.token).merge(workhorse_header) }
+
+ before do
+ allow_next_instance_of(UploadedFile) do |uploaded_file|
+ allow(uploaded_file).to receive(:size).and_return(project.actual_limits.pypi_max_file_size + 1)
+ end
+ end
+
+ it_behaves_like 'returning response status', :bad_request
+ end
end
describe 'GET /api/v4/projects/:id/packages/pypi/files/:sha256/*file_identifier' do
@@ -247,6 +280,26 @@ RSpec.describe API::PypiPackages do
end
end
+ context 'with job token headers' do
+ let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, job.token) }
+
+ context 'valid token' do
+ it_behaves_like 'returning response status', :success
+ end
+
+ context 'invalid token' do
+ let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, 'bar') }
+
+ it_behaves_like 'returning response status', :success
+ end
+
+ context 'invalid user' do
+ let(:headers) { basic_auth_header('foo', job.token) }
+
+ it_behaves_like 'returning response status', :success
+ end
+ end
+
it_behaves_like 'rejects PyPI access with unknown project id'
end
end
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index 1a93be98a67..af6731f3015 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -47,6 +47,17 @@ RSpec.describe API::Search do
end
end
+ shared_examples 'filter by state' do |scope:, search:|
+ it 'respects scope filtering' do
+ get api(endpoint, user), params: { scope: scope, search: search, state: state }
+
+ documents = Gitlab::Json.parse(response.body)
+
+ expect(documents.count).to eq(1)
+ expect(documents.first['state']).to eq(state)
+ end
+ end
+
describe 'GET /search' do
let(:endpoint) { '/search' }
@@ -88,42 +99,84 @@ RSpec.describe API::Search do
end
context 'for issues scope' do
- before do
- create(:issue, project: project, title: 'awesome issue')
+ context 'without filtering by state' do
+ before do
+ create(:issue, project: project, title: 'awesome issue')
- get api(endpoint, user), params: { scope: 'issues', search: 'awesome' }
- end
+ get api(endpoint, user), params: { scope: 'issues', search: 'awesome' }
+ end
- it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
+ it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
- it_behaves_like 'ping counters', scope: :issues
+ it_behaves_like 'ping counters', scope: :issues
- describe 'pagination' do
+ describe 'pagination' do
+ before do
+ create(:issue, project: project, title: 'another issue')
+ end
+
+ include_examples 'pagination', scope: :issues
+ end
+ end
+
+ context 'filter by state' do
before do
- create(:issue, project: project, title: 'another issue')
+ create(:issue, project: project, title: 'awesome opened issue')
+ create(:issue, :closed, project: project, title: 'awesome closed issue')
end
- include_examples 'pagination', scope: :issues
+ context 'state: opened' do
+ let(:state) { 'opened' }
+
+ include_examples 'filter by state', scope: :issues, search: 'awesome'
+ end
+
+ context 'state: closed' do
+ let(:state) { 'closed' }
+
+ include_examples 'filter by state', scope: :issues, search: 'awesome'
+ end
end
end
context 'for merge_requests scope' do
- before do
- create(:merge_request, source_project: repo_project, title: 'awesome mr')
+ context 'without filtering by state' do
+ before do
+ create(:merge_request, source_project: repo_project, title: 'awesome mr')
- get api(endpoint, user), params: { scope: 'merge_requests', search: 'awesome' }
- end
+ get api(endpoint, user), params: { scope: 'merge_requests', search: 'awesome' }
+ end
- it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
+ it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
- it_behaves_like 'ping counters', scope: :merge_requests
+ it_behaves_like 'ping counters', scope: :merge_requests
- describe 'pagination' do
+ describe 'pagination' do
+ before do
+ create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch')
+ end
+
+ include_examples 'pagination', scope: :merge_requests
+ end
+ end
+
+ context 'filter by state' do
before do
- create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch')
+ create(:merge_request, source_project: project, title: 'awesome opened mr')
+ create(:merge_request, :closed, project: project, title: 'awesome closed mr')
end
- include_examples 'pagination', scope: :merge_requests
+ context 'state: opened' do
+ let(:state) { 'opened' }
+
+ include_examples 'filter by state', scope: :merge_requests, search: 'awesome'
+ end
+
+ context 'state: closed' do
+ let(:state) { 'closed' }
+
+ include_examples 'filter by state', scope: :merge_requests, search: 'awesome'
+ end
end
end
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 8db0cdcbc2c..ef12f6dbed3 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -31,7 +31,6 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['ecdsa_key_restriction']).to eq(0)
expect(json_response['ed25519_key_restriction']).to eq(0)
expect(json_response['performance_bar_allowed_group_id']).to be_nil
- expect(json_response['instance_statistics_visibility_private']).to be(false)
expect(json_response['allow_local_requests_from_hooks_and_services']).to be(false)
expect(json_response['allow_local_requests_from_web_hooks_and_services']).to be(false)
expect(json_response['allow_local_requests_from_system_hooks']).to be(true)
@@ -40,6 +39,7 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['snippet_size_limit']).to eq(50.megabytes)
expect(json_response['spam_check_endpoint_enabled']).to be_falsey
expect(json_response['spam_check_endpoint_url']).to be_nil
+ expect(json_response['wiki_page_max_content_bytes']).to be_a(Integer)
end
end
@@ -104,7 +104,6 @@ RSpec.describe API::Settings, 'Settings' do
enforce_terms: true,
terms: 'Hello world!',
performance_bar_allowed_group_path: group.full_path,
- instance_statistics_visibility_private: true,
diff_max_patch_bytes: 150_000,
default_branch_protection: ::Gitlab::Access::PROTECTION_DEV_CAN_MERGE,
local_markdown_version: 3,
@@ -118,7 +117,8 @@ RSpec.describe API::Settings, 'Settings' do
spam_check_endpoint_enabled: true,
spam_check_endpoint_url: 'https://example.com/spam_check',
disabled_oauth_sign_in_sources: 'unknown',
- import_sources: 'github,bitbucket'
+ import_sources: 'github,bitbucket',
+ wiki_page_max_content_bytes: 12345
}
expect(response).to have_gitlab_http_status(:ok)
@@ -146,7 +146,6 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['enforce_terms']).to be(true)
expect(json_response['terms']).to eq('Hello world!')
expect(json_response['performance_bar_allowed_group_id']).to eq(group.id)
- expect(json_response['instance_statistics_visibility_private']).to be(true)
expect(json_response['diff_max_patch_bytes']).to eq(150_000)
expect(json_response['default_branch_protection']).to eq(Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
expect(json_response['local_markdown_version']).to eq(3)
@@ -161,6 +160,7 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['spam_check_endpoint_url']).to eq('https://example.com/spam_check')
expect(json_response['disabled_oauth_sign_in_sources']).to eq([])
expect(json_response['import_sources']).to match_array(%w(github bitbucket))
+ expect(json_response['wiki_page_max_content_bytes']).to eq(12345)
end
end
@@ -239,8 +239,7 @@ RSpec.describe API::Settings, 'Settings' do
snowplow_collector_hostname: "snowplow.example.com",
snowplow_cookie_domain: ".example.com",
snowplow_enabled: true,
- snowplow_app_id: "app_id",
- snowplow_iglu_registry_url: 'https://example.com'
+ snowplow_app_id: "app_id"
}
end
diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb
index 4e2f6e108eb..8d77026d26c 100644
--- a/spec/requests/api/snippets_spec.rb
+++ b/spec/requests/api/snippets_spec.rb
@@ -111,7 +111,7 @@ RSpec.describe API::Snippets do
end
it 'returns 404 for invalid snippet id' do
- snippet.destroy
+ snippet.destroy!
get api("/snippets/#{snippet.id}/raw", author)
@@ -201,7 +201,7 @@ RSpec.describe API::Snippets do
end
it 'returns 404 for invalid snippet id' do
- private_snippet.destroy
+ private_snippet.destroy!
subject
@@ -368,7 +368,7 @@ RSpec.describe API::Snippets do
context 'when the snippet is public' do
let(:extra_params) { { visibility: 'public' } }
- it 'rejects the shippet' do
+ it 'rejects the snippet' do
expect { subject }.not_to change { Snippet.count }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -391,19 +391,16 @@ RSpec.describe API::Snippets do
create(:personal_snippet, :repository, author: user, visibility_level: visibility_level)
end
- shared_examples 'snippet updates' do
- it 'updates a snippet' do
- new_content = 'New content'
- new_description = 'New description'
+ it_behaves_like 'snippet file updates'
+ it_behaves_like 'snippet non-file updates'
+ it_behaves_like 'snippet individual non-file updates'
+ it_behaves_like 'invalid snippet updates'
- update_snippet(params: { content: new_content, description: new_description, visibility: 'internal' })
+ it "returns 404 for another user's snippet" do
+ update_snippet(requester: other_user, params: { title: 'foobar' })
- expect(response).to have_gitlab_http_status(:ok)
- snippet.reload
- expect(snippet.content).to eq(new_content)
- expect(snippet.description).to eq(new_description)
- expect(snippet.visibility).to eq('internal')
- end
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
end
context 'with restricted visibility settings' do
@@ -413,43 +410,7 @@ RSpec.describe API::Snippets do
Gitlab::VisibilityLevel::PRIVATE])
end
- it_behaves_like 'snippet updates'
- end
-
- it_behaves_like 'snippet updates'
-
- it 'returns 404 for invalid snippet id' do
- update_snippet(snippet_id: non_existing_record_id, params: { title: 'Foo' })
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('404 Snippet Not Found')
- end
-
- it "returns 404 for another user's snippet" do
- update_snippet(requester: other_user, params: { title: 'foobar' })
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('404 Snippet Not Found')
- end
-
- it 'returns 400 for missing parameters' do
- update_snippet
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
-
- it 'returns 400 if content is blank' do
- update_snippet(params: { content: '' })
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq 'content is empty'
- end
-
- it 'returns 400 if title is blank' do
- update_snippet(params: { title: '' })
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq 'title is empty'
+ it_behaves_like 'snippet non-file updates'
end
it_behaves_like 'update with repository actions' do
@@ -475,7 +436,7 @@ RSpec.describe API::Snippets do
context 'when the snippet is public' do
let(:visibility_level) { Snippet::PUBLIC }
- it 'rejects the shippet' do
+ it 'rejects the snippet' do
expect { update_snippet(params: { title: 'Foo' }) }
.not_to change { snippet.reload.title }
diff --git a/spec/requests/api/terraform/state_spec.rb b/spec/requests/api/terraform/state_spec.rb
index c47a12456c3..8d128bd911f 100644
--- a/spec/requests/api/terraform/state_spec.rb
+++ b/spec/requests/api/terraform/state_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe API::Terraform::State do
let_it_be(:developer) { create(:user, developer_projects: [project]) }
let_it_be(:maintainer) { create(:user, maintainer_projects: [project]) }
- let!(:state) { create(:terraform_state, :with_file, project: project) }
+ let!(:state) { create(:terraform_state, :with_version, project: project) }
let(:current_user) { maintainer }
let(:auth_header) { user_basic_auth_header(current_user) }
@@ -42,7 +42,7 @@ RSpec.describe API::Terraform::State do
request
expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to eq(state.file.read)
+ expect(response.body).to eq(state.reload.latest_file.read)
end
context 'for a project that does not exist' do
@@ -63,7 +63,7 @@ RSpec.describe API::Terraform::State do
request
expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to eq(state.file.read)
+ expect(response.body).to eq(state.reload.latest_file.read)
end
end
end
@@ -78,7 +78,7 @@ RSpec.describe API::Terraform::State do
request
expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to eq(state.file.read)
+ expect(response.body).to eq(state.reload.latest_file.read)
end
it 'returns unauthorized if the the job is not running' do
@@ -106,14 +106,14 @@ RSpec.describe API::Terraform::State do
request
expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to eq(state.file.read)
+ expect(response.body).to eq(state.reload.latest_file.read)
end
end
end
end
describe 'POST /projects/:id/terraform/state/:name' do
- let(:params) { { 'instance': 'example-instance' } }
+ let(:params) { { 'instance': 'example-instance', 'serial': '1' } }
subject(:request) { post api(state_path), headers: auth_header, as: :json, params: params }
diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb
index dfd0e13d84c..bc315c5b3c6 100644
--- a/spec/requests/api/todos_spec.rb
+++ b/spec/requests/api/todos_spec.rb
@@ -34,6 +34,29 @@ RSpec.describe API::Todos do
end
context 'when authenticated' do
+ context 'when invalid params' do
+ context "invalid action" do
+ it 'returns 400' do
+ get api('/todos', john_doe), params: { action: 'InvalidAction' }
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context "invalid state" do
+ it 'returns 400' do
+ get api('/todos', john_doe), params: { state: 'InvalidState' }
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context "invalid type" do
+ it 'returns 400' do
+ get api('/todos', john_doe), params: { type: 'InvalidType' }
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+
it 'returns an array of pending todos for current user' do
get api('/todos', john_doe)
diff --git a/spec/requests/api/usage_data_spec.rb b/spec/requests/api/usage_data_spec.rb
new file mode 100644
index 00000000000..46dd54dcc73
--- /dev/null
+++ b/spec/requests/api/usage_data_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::UsageData do
+ let_it_be(:user) { create(:user) }
+
+ describe 'POST /usage_data/increment_unique_users' do
+ let(:endpoint) { '/usage_data/increment_unique_users' }
+ let(:known_event) { 'g_compliance_dashboard' }
+ let(:unknown_event) { 'unknown' }
+
+ context 'without CSRF token' do
+ it 'returns forbidden' do
+ stub_feature_flags(usage_data_api: true)
+ allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(false)
+
+ post api(endpoint, user), params: { event: known_event }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'usage_data_api feature not enabled' do
+ it 'returns not_found' do
+ stub_feature_flags(usage_data_api: false)
+
+ post api(endpoint, user), params: { event: known_event }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'without authentication' do
+ it 'returns 401 response' do
+ post api(endpoint), params: { event: known_event }
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'with authentication' do
+ before do
+ stub_feature_flags(usage_data_api: true)
+ stub_feature_flags("usage_data_#{known_event}" => true)
+ stub_application_setting(usage_ping_enabled: true)
+ allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(true)
+ end
+
+ context 'when event is missing from params' do
+ it 'returns bad request' do
+ post api(endpoint, user), params: {}
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'with correct params' do
+ it 'returns status ok' do
+ expect(Gitlab::Redis::HLL).to receive(:add)
+
+ post api(endpoint, user), params: { event: known_event }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'with unknown event' do
+ it 'returns status ok' do
+ expect(Gitlab::Redis::HLL).not_to receive(:add)
+
+ post api(endpoint, user), params: { event: unknown_event }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 6c6497a240b..806b586ef49 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -348,6 +348,26 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(json_response.first.keys).not_to include 'is_admin'
end
+
+ context 'exclude_internal param' do
+ let_it_be(:internal_user) { User.alert_bot }
+
+ it 'returns all users when it is not set' do
+ get api("/users?exclude_internal=false", user)
+
+ expect(response).to match_response_schema('public_api/v4/user/basics')
+ expect(response).to include_pagination_headers
+ expect(json_response.map { |u| u['id'] }).to include(internal_user.id)
+ end
+
+ it 'returns all non internal users when it is set' do
+ get api("/users?exclude_internal=true", user)
+
+ expect(response).to match_response_schema('public_api/v4/user/basics')
+ expect(response).to include_pagination_headers
+ expect(json_response.map { |u| u['id'] }).not_to include(internal_user.id)
+ end
+ end
end
context "when admin" do
@@ -894,6 +914,50 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
expect(response).to have_gitlab_http_status(:ok)
end
+ context 'updating password' do
+ def update_password(user, admin, password = User.random_password)
+ put api("/users/#{user.id}", admin), params: { password: password }
+ end
+
+ context 'admin updates their own password' do
+ it 'does not force reset on next login' do
+ update_password(admin, admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(user.reload.password_expired?).to eq(false)
+ end
+
+ it 'does not enqueue the `admin changed your password` email' do
+ expect { update_password(admin, admin) }
+ .not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+
+ it 'enqueues the `password changed` email' do
+ expect { update_password(admin, admin) }
+ .to have_enqueued_mail(DeviseMailer, :password_change)
+ end
+ end
+
+ context 'admin updates the password of another user' do
+ it 'forces reset on next login' do
+ update_password(user, admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(user.reload.password_expired?).to eq(true)
+ end
+
+ it 'enqueues the `admin changed your password` email' do
+ expect { update_password(user, admin) }
+ .to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+
+ it 'does not enqueue the `password changed` email' do
+ expect { update_password(user, admin) }
+ .not_to have_enqueued_mail(DeviseMailer, :password_change)
+ end
+ end
+ end
+
it "updates user with new bio" do
put api("/users/#{user.id}", admin), params: { bio: 'new test bio' }
@@ -920,13 +984,6 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
expect(user.reload.bio).to eq('')
end
- it "updates user with new password and forces reset on next login" do
- put api("/users/#{user.id}", admin), params: { password: '12345678' }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(user.reload.password_expires_at).to be <= Time.now
- end
-
it "updates user with organization" do
put api("/users/#{user.id}", admin), params: { organization: 'GitLab' }
@@ -1377,7 +1434,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
end
end
- describe 'POST /users/:id/keys' do
+ describe 'POST /users/:id/gpg_keys' do
it 'does not create invalid GPG key' do
post api("/users/#{user.id}/gpg_keys", admin)
diff --git a/spec/requests/api/v3/github_spec.rb b/spec/requests/api/v3/github_spec.rb
new file mode 100644
index 00000000000..86ddf4a78d8
--- /dev/null
+++ b/spec/requests/api/v3/github_spec.rb
@@ -0,0 +1,516 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::V3::Github do
+ let(:user) { create(:user) }
+ let(:unauthorized_user) { create(:user) }
+ let(:admin) { create(:user, :admin) }
+ let(:project) { create(:project, :repository, creator: user) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ describe 'GET /orgs/:namespace/repos' do
+ it 'returns an empty array' do
+ group = create(:group)
+
+ jira_get v3_api("/orgs/#{group.path}/repos", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+
+ it 'returns 200 when namespace path include a dot' do
+ group = create(:group, path: 'foo.bar')
+
+ jira_get v3_api("/orgs/#{group.path}/repos", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ describe 'GET /user/repos' do
+ it 'returns an empty array' do
+ jira_get v3_api('/user/repos', user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+
+ shared_examples_for 'Jira-specific mimicked GitHub endpoints' do
+ describe 'GET /.../issues/:id/comments' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project, target_project: project)
+ end
+
+ let!(:note) do
+ create(:note, project: project, noteable: merge_request)
+ end
+
+ context 'when user has access to the merge request' do
+ it 'returns an array of notes' do
+ jira_get v3_api("/repos/#{path}/issues/#{merge_request.id}/comments", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(1)
+ end
+ end
+
+ context 'when user has no access to the merge request' do
+ let(:project) { create(:project, :private) }
+
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns 404' do
+ jira_get v3_api("/repos/#{path}/issues/#{merge_request.id}/comments", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'GET /.../pulls/:id/commits' do
+ it 'returns an empty array' do
+ jira_get v3_api("/repos/#{path}/pulls/xpto/commits", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+
+ describe 'GET /.../pulls/:id/comments' do
+ it 'returns an empty array' do
+ jira_get v3_api("/repos/#{path}/pulls/xpto/comments", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+ end
+
+ # Here we test that using /-/jira as namespace/project still works,
+ # since that is how old Jira setups will talk to us
+ context 'old /-/jira endpoints' do
+ it_behaves_like 'Jira-specific mimicked GitHub endpoints' do
+ let(:path) { '-/jira' }
+ end
+
+ it 'returns an empty Array for events' do
+ jira_get v3_api('/repos/-/jira/events', user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+
+ context 'new :namespace/:project jira endpoints' do
+ it_behaves_like 'Jira-specific mimicked GitHub endpoints' do
+ let(:path) { "#{project.namespace.path}/#{project.path}" }
+ end
+
+ describe 'GET /users/:username' do
+ let!(:user1) { create(:user, username: 'jane.porter') }
+
+ context 'user exists' do
+ it 'responds with the expected user' do
+ jira_get v3_api("/users/#{user.username}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('entities/github/user')
+ end
+ end
+
+ context 'user does not exist' do
+ it 'responds with the expected status' do
+ jira_get v3_api('/users/unknown_user_name', user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'no rights to request user lists' do
+ before do
+ expect(Ability).to receive(:allowed?).with(unauthorized_user, :read_users_list, :global).and_return(false)
+ expect(Ability).to receive(:allowed?).at_least(:once).and_call_original
+ end
+
+ it 'responds with forbidden' do
+ jira_get v3_api("/users/#{user.username}", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ describe 'GET events' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :empty_repo, path: 'project.with.dot', group: group) }
+ let(:events_path) { "/repos/#{group.path}/#{project.path}/events" }
+
+ context 'if there are no merge requests' do
+ it 'returns an empty array' do
+ jira_get v3_api(events_path, user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+
+ context 'if there is a merge request' do
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) }
+
+ it 'returns an event' do
+ jira_get v3_api(events_path, user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(1)
+ end
+ end
+
+ context 'if there are more merge requests' do
+ let!(:merge_request) { create(:merge_request, id: 10000, source_project: project, target_project: project, author: user) }
+ let!(:merge_request2) { create(:merge_request, id: 10001, source_project: project, source_branch: generate(:branch), target_project: project, author: user) }
+
+ it 'returns the expected amount of events' do
+ jira_get v3_api(events_path, user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(2)
+ end
+
+ it 'ensures each event has a unique id' do
+ jira_get v3_api(events_path, user)
+
+ ids = json_response.map { |event| event['id'] }.uniq
+ expect(ids.size).to eq(2)
+ end
+ end
+ end
+ end
+
+ describe 'repo pulls' do
+ let(:project2) { create(:project, :repository, creator: user) }
+ let(:assignee) { create(:user) }
+ let(:assignee2) { create(:user) }
+ let!(:merge_request) do
+ create(:merge_request, source_project: project, target_project: project, author: user, assignees: [assignee])
+ end
+
+ let!(:merge_request_2) do
+ create(:merge_request, source_project: project2, target_project: project2, author: user, assignees: [assignee, assignee2])
+ end
+
+ before do
+ project2.add_maintainer(user)
+ end
+
+ describe 'GET /-/jira/pulls' do
+ it 'returns an array of merge requests with github format' do
+ jira_get v3_api('/repos/-/jira/pulls', user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(2)
+ expect(response).to match_response_schema('entities/github/pull_requests')
+ end
+ end
+
+ describe 'GET /repos/:namespace/:project/pulls' do
+ it 'returns an array of merge requests for the proper project in github format' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(1)
+ expect(response).to match_response_schema('entities/github/pull_requests')
+ end
+ end
+
+ describe 'GET /repos/:namespace/:project/pulls/:id' do
+ 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)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('entities/github/pull_request')
+ end
+ end
+
+ context 'when user has no access to the merge request' do
+ it 'returns 404' do
+ project.add_guest(unauthorized_user)
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls/#{merge_request.id}", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ 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)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('entities/github/pull_request')
+ end
+ end
+ end
+ end
+
+ 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)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(response).to match_response_schema('entities/github/repositories')
+
+ projects.each do |project|
+ hash = json_response.find do |hash|
+ hash['name'] == ::Gitlab::Jira::Dvcs.encode_project_name(project)
+ end
+
+ raise "Project #{project.full_path} not present in response" if hash.nil?
+
+ expect(hash['owner']['login']).to eq(namespace.path)
+ end
+ expect(json_response.size).to eq(projects.size)
+ end
+
+ context 'group namespace' do
+ let(:project) { create(:project, group: group) }
+ let!(:project2) { create(:project, :public, group: group) }
+
+ it 'returns an array of projects belonging to group excluding the ones user is not directly a member of, even when public' do
+ expect_project_under_namespace([project], group, user)
+ end
+
+ context 'when instance admin' do
+ let(:user) { create(:user, :admin) }
+
+ it 'returns an array of projects belonging to group' do
+ expect_project_under_namespace([project, project2], group, user)
+ end
+
+ context 'with a private group' do
+ let(:group) { create(:group, :private) }
+ 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)
+ end
+ end
+ end
+ end
+
+ context 'nested group namespace' do
+ let(:group) { create(:group, :nested) }
+ let!(:parent_group_project) { create(:project, group: group.parent, name: 'parent_group_project') }
+ let!(:child_group_project) { create(:project, group: group, name: 'child_group_project') }
+
+ before do
+ group.parent.add_maintainer(user)
+ end
+
+ it 'returns an array of projects belonging to group with github format' do
+ expect_project_under_namespace([parent_group_project, child_group_project], group.parent, user)
+ end
+
+ it 'avoids N+1 queries' do
+ jira_get v3_api("/users/#{group.parent.path}/repos", user)
+
+ control = ActiveRecord::QueryRecorder.new { jira_get v3_api("/users/#{group.parent.path}/repos", user) }
+
+ new_group = create(:group, parent: group.parent)
+ create(:project, :repository, group: new_group, creator: user)
+
+ expect { jira_get v3_api("/users/#{group.parent.path}/repos", user) }.not_to exceed_query_limit(control)
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'user namespace' do
+ let(:project) { create(:project, namespace: user.namespace) }
+
+ it 'returns an array of projects belonging to user namespace with github format' do
+ expect_project_under_namespace([project], user.namespace, user)
+ end
+ end
+
+ context 'namespace path includes a dot' do
+ let(:project) { create(:project, group: group) }
+ let(:group) { create(:group, name: 'foo.bar') }
+
+ before do
+ group.add_maintainer(user)
+ end
+
+ it 'returns an array of projects belonging to group with github format' do
+ expect_project_under_namespace([project], group, user)
+ end
+ end
+
+ context 'unauthenticated' do
+ it 'returns 401' do
+ jira_get v3_api('/users/foo/repos', nil)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'namespace does not exist' do
+ it 'responds with not found status' do
+ jira_get v3_api('/users/noo/repos', user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'GET /repos/:namespace/:project/branches' do
+ context 'authenticated' do
+ context 'updating project feature usage' do
+ it 'counts Jira Cloud integration as enabled' do
+ user_agent = 'Jira DVCS Connector Vertigo/4.42.0'
+
+ Timecop.freeze do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user), user_agent
+
+ expect(project.reload.jira_dvcs_cloud_last_sync_at).to be_like_time(Time.now)
+ end
+ end
+
+ it 'counts Jira Server integration as enabled' do
+ user_agent = 'Jira DVCS Connector/3.2.4'
+
+ Timecop.freeze do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user), user_agent
+
+ expect(project.reload.jira_dvcs_server_last_sync_at).to be_like_time(Time.now)
+ end
+ end
+ end
+
+ it 'returns an array of project branches with github format' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an(Array)
+
+ expect(response).to match_response_schema('entities/github/branches')
+ end
+
+ it 'returns 200 when project path include a dot' do
+ project.update!(path: 'foo.bar')
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns 200 when namespace path include a dot' do
+ group = create(:group, path: 'foo.bar')
+ project = create(:project, :repository, group: group)
+ project.add_reporter(user)
+
+ jira_get v3_api("/repos/#{group.path}/#{project.path}/branches", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'unauthenticated' do
+ it 'returns 401' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", nil)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'unauthorized' do
+ it 'returns 404 when lower access level' do
+ project.add_guest(unauthorized_user)
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'GET /repos/:namespace/:project/commits/:sha' do
+ let(:commit) { project.repository.commit }
+ let(:commit_id) { commit.id }
+
+ context 'authenticated' do
+ it 'returns commit with github format' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('entities/github/commit')
+ end
+
+ it 'returns 200 when project path include a dot' do
+ project.update!(path: 'foo.bar')
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns 200 when namespace path include a dot' do
+ group = create(:group, path: 'foo.bar')
+ project = create(:project, :repository, group: group)
+ project.add_reporter(user)
+
+ jira_get v3_api("/repos/#{group.path}/#{project.path}/commits/#{commit_id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'unauthenticated' do
+ it 'returns 401' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", nil)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'unauthorized' do
+ it 'returns 404 when lower access level' do
+ project.add_guest(unauthorized_user)
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}",
+ unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ def jira_get(path, user_agent = 'Jira DVCS Connector/3.2.4')
+ get path, headers: { 'User-Agent' => user_agent }
+ end
+
+ def v3_api(path, user = nil, personal_access_token: nil, oauth_access_token: nil)
+ api(
+ path,
+ user,
+ version: 'v3',
+ personal_access_token: personal_access_token,
+ oauth_access_token: oauth_access_token
+ )
+ end
+end
diff --git a/spec/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb
index 7bb73e9664b..1ae9b0d548d 100644
--- a/spec/requests/api/variables_spec.rb
+++ b/spec/requests/api/variables_spec.rb
@@ -60,25 +60,10 @@ RSpec.describe API::Variables do
let!(:var2) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'production') }
context 'when filter[environment_scope] is not passed' do
- context 'FF ci_variables_api_filter_environment_scope is enabled' do
- it 'returns 409' do
- get api("/projects/#{project.id}/variables/key1", user)
+ it 'returns 409' do
+ get api("/projects/#{project.id}/variables/key1", user)
- expect(response).to have_gitlab_http_status(:conflict)
- end
- end
-
- context 'FF ci_variables_api_filter_environment_scope is disabled' do
- before do
- stub_feature_flags(ci_variables_api_filter_environment_scope: false)
- end
-
- it 'returns random one' do
- get api("/projects/#{project.id}/variables/key1", user)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['key']).to eq('key1')
- end
+ expect(response).to have_gitlab_http_status(:conflict)
end
end
@@ -232,25 +217,10 @@ RSpec.describe API::Variables do
let!(:var2) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'production') }
context 'when filter[environment_scope] is not passed' do
- context 'FF ci_variables_api_filter_environment_scope is enabled' do
- it 'returns 409' do
- get api("/projects/#{project.id}/variables/key1", user)
+ it 'returns 409' do
+ get api("/projects/#{project.id}/variables/key1", user)
- expect(response).to have_gitlab_http_status(:conflict)
- end
- end
-
- context 'FF ci_variables_api_filter_environment_scope is disabled' do
- before do
- stub_feature_flags(ci_variables_api_filter_environment_scope: false)
- end
-
- it 'updates random one' do
- put api("/projects/#{project.id}/variables/key1", user), params: { value: 'new_val' }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['value']).to eq('new_val')
- end
+ expect(response).to have_gitlab_http_status(:conflict)
end
end
@@ -312,26 +282,10 @@ RSpec.describe API::Variables do
let!(:var2) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'production') }
context 'when filter[environment_scope] is not passed' do
- context 'FF ci_variables_api_filter_environment_scope is enabled' do
- it 'returns 409' do
- get api("/projects/#{project.id}/variables/key1", user)
-
- expect(response).to have_gitlab_http_status(:conflict)
- end
- end
-
- context 'FF ci_variables_api_filter_environment_scope is disabled' do
- before do
- stub_feature_flags(ci_variables_api_filter_environment_scope: false)
- end
+ it 'returns 409' do
+ get api("/projects/#{project.id}/variables/key1", user)
- it 'deletes random one' do
- expect do
- delete api("/projects/#{project.id}/variables/key1", user), params: { 'filter[environment_scope]': 'production' }
-
- expect(response).to have_gitlab_http_status(:no_content)
- end.to change {project.variables.count}.by(-1)
- end
+ expect(response).to have_gitlab_http_status(:conflict)
end
end
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index 5b6ffabb7ac..dbba2b35d74 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -763,7 +763,7 @@ RSpec.describe 'Git HTTP requests' do
context 'and build created by' do
before do
- build.update(user: user)
+ build.update!(user: user)
project.add_reporter(user)
end
diff --git a/spec/requests/jira_authorizations_spec.rb b/spec/requests/jira_authorizations_spec.rb
new file mode 100644
index 00000000000..24c6001814c
--- /dev/null
+++ b/spec/requests/jira_authorizations_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Jira authorization requests' do
+ let(:user) { create :user }
+ let(:application) { create :oauth_application, scopes: 'api' }
+ let(:redirect_uri) { oauth_jira_callback_url(host: "http://www.example.com") }
+
+ def generate_access_grant
+ create :oauth_access_grant, application: application, resource_owner_id: user.id, redirect_uri: redirect_uri
+ end
+
+ describe 'POST access_token' do
+ let(:client_id) { application.uid }
+ let(:client_secret) { application.secret }
+
+ it 'returns values similar to a POST to /oauth/token' do
+ post_data = {
+ client_id: client_id,
+ client_secret: client_secret
+ }
+
+ post '/oauth/token', params: post_data.merge({
+ code: generate_access_grant.token,
+ grant_type: 'authorization_code',
+ redirect_uri: redirect_uri
+ })
+ oauth_response = json_response
+
+ post '/login/oauth/access_token', params: post_data.merge({
+ code: generate_access_grant.token
+ })
+ jira_response = response.body
+
+ access_token, scope, token_type = oauth_response.values_at('access_token', 'scope', 'token_type')
+ expect(jira_response).to eq("access_token=#{access_token}&scope=#{scope}&token_type=#{token_type}")
+ end
+
+ context 'when authorization fails' do
+ before do
+ post '/login/oauth/access_token', params: {
+ client_id: client_id,
+ client_secret: client_secret,
+ code: try(:code) || generate_access_grant.token
+ }
+ end
+
+ shared_examples 'an unauthorized request' do
+ it 'returns 401' do
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when client_id is invalid' do
+ let(:client_id) { "invalid_id" }
+
+ it_behaves_like 'an unauthorized request'
+ end
+
+ context 'when client_secret is invalid' do
+ let(:client_secret) { "invalid_secret" }
+
+ it_behaves_like 'an unauthorized request'
+ end
+
+ context 'when code is invalid' do
+ let(:code) { "invalid_code" }
+
+ it 'returns bad request' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/jira_routing_spec.rb b/spec/requests/jira_routing_spec.rb
new file mode 100644
index 00000000000..a627eea33a8
--- /dev/null
+++ b/spec/requests/jira_routing_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Jira referenced paths', type: :request do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:user) { create(:user) }
+
+ let(:group) { create(:group, name: 'group') }
+ let(:sub_group) { create(:group, name: 'subgroup', parent: group) }
+
+ let!(:group_project) { create(:project, name: 'group_project', namespace: group) }
+ let!(:sub_group_project) { create(:project, name: 'sub_group_project', namespace: sub_group) }
+
+ before do
+ group.add_owner(user)
+
+ login_as user
+ end
+
+ def redirects_to_canonical_path(jira_path, redirect_path)
+ get(jira_path)
+
+ expect(response).to redirect_to(redirect_path)
+ end
+
+ context 'with encoded subgroup path' do
+ where(:jira_path, :redirect_path) do
+ '/group/group@sub_group@sub_group_project' | '/group/sub_group/sub_group_project'
+ '/group@sub_group/group@sub_group@sub_group_project' | '/group/sub_group/sub_group_project'
+ '/group/group@sub_group@sub_group_project/commit/1234567' | '/group/sub_group/sub_group_project/commit/1234567'
+ '/group/group@sub_group@sub_group_project/tree/1234567' | '/group/sub_group/sub_group_project/-/tree/1234567'
+ end
+
+ with_them do
+ context 'with legacy prefix' do
+ it 'redirects to canonical path' do
+ redirects_to_canonical_path "/-/jira#{jira_path}", redirect_path
+ end
+ end
+
+ it 'redirects to canonical path' do
+ redirects_to_canonical_path jira_path, redirect_path
+ end
+ end
+ end
+
+ context 'regular paths with legacy prefix' do
+ where(:jira_path, :redirect_path) do
+ '/-/jira/group/group_project' | '/group/group_project'
+ '/-/jira/group/group_project/commit/1234567' | '/group/group_project/commit/1234567'
+ '/-/jira/group/group_project/tree/1234567' | '/group/group_project/-/tree/1234567'
+ end
+
+ with_them do
+ it 'redirects to canonical path' do
+ redirects_to_canonical_path jira_path, redirect_path
+ end
+ end
+ end
+
+ context 'when tree path has an @' do
+ let(:path) { '/group/project/tree/folder-with-@' }
+
+ it 'does not do a redirect' do
+ get path
+
+ expect(response).not_to have_gitlab_http_status(:moved_permanently)
+ end
+ end
+end
diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb
index fd4261fb50d..31bb0586e9f 100644
--- a/spec/requests/lfs_http_spec.rb
+++ b/spec/requests/lfs_http_spec.rb
@@ -786,7 +786,7 @@ RSpec.describe 'Git LFS API and storage' do
let(:authorization) { authorize_deploy_key }
let(:update_user_permissions) do
- project.deploy_keys_projects.create(deploy_key: key, can_push: true)
+ project.deploy_keys_projects.create!(deploy_key: key, can_push: true)
end
it_behaves_like 'pushes new LFS objects', renew_authorization: false
@@ -991,7 +991,7 @@ RSpec.describe 'Git LFS API and storage' do
context 'and workhorse requests upload finalize for a new LFS object' do
before do
- lfs_object.destroy
+ lfs_object.destroy!
end
context 'with object storage disabled' do
@@ -1009,7 +1009,7 @@ RSpec.describe 'Git LFS API and storage' do
end
let(:tmp_object) do
- fog_connection.directories.new(key: 'lfs-objects').files.create(
+ fog_connection.directories.new(key: 'lfs-objects').files.create( # rubocop: disable Rails/SaveBang
key: 'tmp/uploads/12312300',
body: 'content'
)
@@ -1080,7 +1080,7 @@ RSpec.describe 'Git LFS API and storage' do
context 'without the lfs object' do
before do
- lfs_object.destroy
+ lfs_object.destroy!
end
it 'rejects slashes in the tempfile name (path traversal)' do
diff --git a/spec/requests/profiles/notifications_controller_spec.rb b/spec/requests/profiles/notifications_controller_spec.rb
index d60cee00aef..87669b3594c 100644
--- a/spec/requests/profiles/notifications_controller_spec.rb
+++ b/spec/requests/profiles/notifications_controller_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'view user notifications' do
let(:user) do
create(:user) do |user|
- user.emails.create(email: 'original@example.com', confirmed_at: Time.current)
- user.emails.create(email: 'new@example.com', confirmed_at: Time.current)
+ user.emails.create!(email: 'original@example.com', confirmed_at: Time.current)
+ user.emails.create!(email: 'new@example.com', confirmed_at: Time.current)
user.notification_email = 'original@example.com'
user.save!
end
@@ -25,7 +25,7 @@ RSpec.describe 'view user notifications' do
end
describe 'GET /profile/notifications' do
- it 'avoid N+1 due to an additional groups (with no parent group)' do
+ it 'does not have an N+1 due to an additional groups (with no parent group)' do
get_profile_notifications
control = ActiveRecord::QueryRecorder.new do
diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb
index 8c3058d405c..4338bfa3759 100644
--- a/spec/requests/projects/cycle_analytics_events_spec.rb
+++ b/spec/requests/projects/cycle_analytics_events_spec.rb
@@ -73,15 +73,6 @@ RSpec.describe 'value stream analytics events' do
expect(json_response['events'].first['date']).not_to be_empty
end
- it 'lists the production events', :sidekiq_might_not_need_inline do
- get project_cycle_analytics_production_path(project, format: :json)
-
- first_issue_iid = project.issues.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
-
- expect(json_response['events']).not_to be_empty
- expect(json_response['events'].first['iid']).to eq(first_issue_iid)
- end
-
context 'specific branch' do
it 'lists the test events', :sidekiq_might_not_need_inline do
branch = project.merge_requests.first.source_branch
diff --git a/spec/requests/projects/issue_links_controller_spec.rb b/spec/requests/projects/issue_links_controller_spec.rb
new file mode 100644
index 00000000000..a21c676f000
--- /dev/null
+++ b/spec/requests/projects/issue_links_controller_spec.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::IssueLinksController do
+ let(:user) { create :user }
+ let(:project) { create(:project_empty_repo) }
+ let(:issue) { create :issue, project: project }
+
+ describe 'GET /*namespace_id/:project_id/issues/:issue_id/links' do
+ let(:issue_b) { create :issue, project: project }
+ let!(:issue_link) { create :issue_link, source: issue, target: issue_b }
+
+ before do
+ project.add_guest(user)
+ login_as user
+ end
+
+ it 'returns JSON response' do
+ list_service_response = IssueLinks::ListService.new(issue, user).execute
+
+ get namespace_project_issue_links_path(issue_links_params)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq(list_service_response.as_json)
+ end
+ end
+
+ describe 'POST /*namespace_id/:project_id/issues/:issue_id/links' do
+ let(:issue_b) { create :issue, project: project }
+
+ before do
+ project.add_role(user, user_role)
+ login_as user
+ end
+
+ context 'with success' do
+ let(:user_role) { :developer }
+ let(:issuable_references) { [issue_b.to_reference] }
+
+ it 'returns success JSON' do
+ post namespace_project_issue_links_path(issue_links_params(issuable_references: issuable_references))
+
+ 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)
+ end
+ end
+
+ context 'with failure' do
+ context 'when unauthorized' do
+ let(:user_role) { :guest }
+ let(:issuable_references) { [issue_b.to_reference] }
+
+ it 'returns 403' do
+ post namespace_project_issue_links_path(issue_links_params(issuable_references: issuable_references))
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when failing service result' do
+ let(:user_role) { :developer }
+ let(:issuable_references) { ["##{non_existing_record_iid}"] }
+
+ it 'returns failure JSON' do
+ post namespace_project_issue_links_path(issue_links_params(issuable_references: issuable_references))
+
+ list_service_response = IssueLinks::ListService.new(issue, user).execute
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response).to eq('message' => 'No Issue found for given params', 'issuables' => list_service_response.as_json)
+ end
+ end
+ end
+ end
+
+ describe 'DELETE /*namespace_id/:project_id/issues/:issue_id/link/:id' do
+ let(:issue_link) { create :issue_link, source: issue, target: referenced_issue }
+
+ before do
+ project.add_role(user, user_role)
+ login_as user
+ end
+
+ context 'when unauthorized' do
+ context 'when no authorization on current project' do
+ let(:referenced_issue) { create :issue, project: project }
+ let(:user_role) { :guest }
+
+ it 'returns 403' do
+ delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id))
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when no authorization on the related issue project' do
+ # unauthorized project issue
+ let(:referenced_issue) { create :issue }
+ let(:user_role) { :developer }
+
+ it 'returns 404' do
+ delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id))
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when authorized' do
+ let(:referenced_issue) { create :issue, project: project }
+ let(:user_role) { :developer }
+
+ it 'returns success JSON' do
+ delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id))
+
+ list_service_response = IssueLinks::ListService.new(issue, user).execute
+
+ expect(json_response).to eq('issuables' => list_service_response.as_json)
+ end
+ end
+
+ context 'when non of issues of the link is not the issue requested in the path' do
+ let(:referenced_issue) { create(:issue, project: project) }
+ let(:another_issue) { create(:issue, project: project) }
+ let(:issue) { create(:issue, project: project) }
+ let(:user_role) { :developer }
+
+ let!(:issue_link) { create :issue_link, source: another_issue, target: referenced_issue }
+
+ subject do
+ delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id))
+ end
+
+ it 'returns 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'does not delete the link' do
+ expect { subject }.not_to change { IssueLink.count }.from(1)
+ end
+ end
+ end
+
+ def issue_links_params(opts = {})
+ 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/metrics_dashboard_spec.rb b/spec/requests/projects/metrics_dashboard_spec.rb
index f571e4a4309..f0e0e6a02ee 100644
--- a/spec/requests/projects/metrics_dashboard_spec.rb
+++ b/spec/requests/projects/metrics_dashboard_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'metrics dashboard page' do
+RSpec.describe 'Projects::MetricsDashboardController' do
let_it_be(:project) { create(:project) }
let_it_be(:environment) { create(:environment, project: project) }
let_it_be(:environment2) { create(:environment, project: project) }
@@ -14,28 +14,42 @@ RSpec.describe 'metrics dashboard page' do
end
describe 'GET /:namespace/:project/-/metrics' do
- it 'returns 200' do
+ it "redirects to default environment's metrics dashboard" do
send_request
- expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to redirect_to(dashboard_route(environment: environment))
end
- it 'assigns environment' do
+ it 'assigns default_environment' do
send_request
- expect(assigns(:environment).id).to eq(environment.id)
+ expect(assigns(:default_environment).id).to eq(environment.id)
+ end
+
+ it 'retains existing parameters when redirecting' do
+ params = {
+ dashboard_path: '.gitlab/dashboards/dashboard_path.yml',
+ page: 'panel/new',
+ group: 'System metrics (Kubernetes)',
+ title: 'Memory Usage (Pod average)',
+ y_label: 'Memory Used per Pod (MB)'
+ }
+ send_request(params)
+
+ expect(response).to redirect_to(dashboard_route(params.merge(environment: environment.id)))
end
context 'with anonymous user and public dashboard visibility' do
let(:anonymous_user) { create(:user) }
- let(:project) do
- create(:project, :public, metrics_dashboard_access_level: 'enabled')
- end
+ let(:project) { create(:project, :public) }
before do
+ project.update!(metrics_dashboard_access_level: 'enabled')
+
login_as(anonymous_user)
end
it 'returns 200' do
send_request
+
expect(response).to have_gitlab_http_status(:ok)
end
end
@@ -64,12 +78,12 @@ RSpec.describe 'metrics dashboard page' do
let(:dashboard_path) { '.gitlab/dashboards/dashboard_path.yml' }
it 'returns 200' do
- send_request(dashboard_path: dashboard_path)
+ send_request(dashboard_path: dashboard_path, environment: environment.id)
expect(response).to have_gitlab_http_status(:ok)
end
it 'assigns environment' do
- send_request(dashboard_path: dashboard_path)
+ send_request(dashboard_path: dashboard_path, environment: environment.id)
expect(assigns(:environment).id).to eq(environment.id)
end
end
@@ -97,15 +111,13 @@ RSpec.describe 'metrics dashboard page' do
describe 'GET :/namespace/:project/-/metrics/:page' do
it 'returns 200 with path param page' do
- # send_request(page: 'panel/new') cannot be used because it encodes '/'
- get "#{dashboard_route}/panel/new"
+ send_request(page: 'panel/new', environment: environment.id)
expect(response).to have_gitlab_http_status(:ok)
end
it 'returns 200 with dashboard and path param page' do
- # send_request(page: 'panel/new') cannot be used because it encodes '/'
- get "#{dashboard_route(dashboard_path: 'dashboard.yml')}/panel/new"
+ send_request(dashboard_path: 'dashboard.yml', page: 'panel/new', environment: environment.id)
expect(response).to have_gitlab_http_status(:ok)
end
diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb
index 13e371ad68a..fedafff0d1b 100644
--- a/spec/routing/admin_routing_spec.rb
+++ b/spec/routing/admin_routing_spec.rb
@@ -134,6 +134,20 @@ RSpec.describe Admin::HealthCheckController, "routing" do
end
end
+# admin_dev_ops_report GET /admin/dev_ops_report(.:format) admin/dev_ops_report#show
+RSpec.describe Admin::DevOpsReportController, "routing" do
+ it "to #show" do
+ expect(get("/admin/dev_ops_report")).to route_to('admin/dev_ops_report#show')
+ end
+end
+
+# admin_cohorts GET /admin/cohorts(.:format) admin/cohorst#index
+RSpec.describe Admin::CohortsController, "routing" do
+ it "to #index" do
+ expect(get("/admin/cohorts")).to route_to('admin/cohorts#index')
+ end
+end
+
RSpec.describe Admin::GroupsController, "routing" do
let(:name) { 'complex.group-namegit' }
@@ -164,3 +178,9 @@ RSpec.describe Admin::SessionsController, "routing" do
expect(post("/admin/session/destroy")).to route_to('admin/sessions#destroy')
end
end
+
+RSpec.describe Admin::PlanLimitsController, "routing" do
+ it "to #create" do
+ expect(post("/admin/plan_limits")).to route_to('admin/plan_limits#create')
+ end
+end
diff --git a/spec/routing/instance_statistics_routing_spec.rb b/spec/routing/instance_statistics_routing_spec.rb
index 7793c5cce71..7eec807fb0b 100644
--- a/spec/routing/instance_statistics_routing_spec.rb
+++ b/spec/routing/instance_statistics_routing_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe 'Instance Statistics', 'routing' do
include RSpec::Rails::RequestExampleGroup
- it "routes '/-/instance_statistics' to dev ops score" do
- expect(get('/-/instance_statistics')).to redirect_to('/-/instance_statistics/dev_ops_score')
+ it "routes '/-/instance_statistics' to dev ops report" do
+ expect(get('/-/instance_statistics')).to redirect_to('/admin/dev_ops_report')
end
end
diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb
index af4becd980b..722e687838f 100644
--- a/spec/routing/routing_spec.rb
+++ b/spec/routing/routing_spec.rb
@@ -32,13 +32,6 @@ RSpec.describe UsersController, "routing" do
expect(get("/users/User/snippets")).to route_to('users#snippets', username: 'User')
end
- # get all the ssh-keys of a user
- it "to #get_keys" do
- allow_any_instance_of(::Constraints::UserUrlConstrainer).to receive(:matches?).and_return(true)
-
- expect(get("/User.keys")).to route_to('users#ssh_keys', username: 'User')
- end
-
it "to #calendar" do
expect(get("/users/User/calendar")).to route_to('users#calendar', username: 'User')
end
@@ -193,6 +186,12 @@ RSpec.describe Profiles::KeysController, "routing" do
it "to #destroy" do
expect(delete("/profile/keys/1")).to route_to('profiles/keys#destroy', id: '1')
end
+
+ it "to #get_keys" do
+ allow_any_instance_of(::Constraints::UserUrlConstrainer).to receive(:matches?).and_return(true)
+
+ expect(get("/foo.keys")).to route_to('profiles/keys#get_keys', username: 'foo')
+ end
end
# emails GET /emails(.:format) emails#index
diff --git a/spec/rubocop/cop/active_record_association_reload_spec.rb b/spec/rubocop/cop/active_record_association_reload_spec.rb
index 79053a79c5a..e8d46064b49 100644
--- a/spec/rubocop/cop/active_record_association_reload_spec.rb
+++ b/spec/rubocop/cop/active_record_association_reload_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe RuboCop::Cop::ActiveRecordAssociationReload, type: :rubocop do
users = User.all
users.reload
^^^^^^ Use reset instead of reload. For more details check the https://gitlab.com/gitlab-org/gitlab-foss/issues/60218.
- PATTERN
+ PATTERN
end
it 'does not register an offense on reset usage' do
diff --git a/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb b/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb
new file mode 100644
index 00000000000..8341b0cab3a
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../../rubocop/cop/gitlab/avoid_uploaded_file_from_params'
+
+RSpec.describe RuboCop::Cop::Gitlab::AvoidUploadedFileFromParams, type: :rubocop do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'UploadedFile.from_params' do
+ it 'flags its call' do
+ expect_offense(<<~SOURCE)
+ UploadedFile.from_params(params)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use the `UploadedFile` set by `multipart.rb` instead of calling `UploadedFile.from_params` directly. See https://docs.gitlab.com/ee/development/uploads.html#how-to-add-a-new-upload-route
+ SOURCE
+ end
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/bulk_insert_spec.rb b/spec/rubocop/cop/gitlab/bulk_insert_spec.rb
index 2766e4f1982..d1236865897 100644
--- a/spec/rubocop/cop/gitlab/bulk_insert_spec.rb
+++ b/spec/rubocop/cop/gitlab/bulk_insert_spec.rb
@@ -13,7 +13,14 @@ RSpec.describe RuboCop::Cop::Gitlab::BulkInsert, type: :rubocop do
it 'flags the use of Gitlab::Database.bulk_insert' do
expect_offense(<<~SOURCE)
Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use the `BulkInsertSafe` concern, instead of using `Gitlab::Database.bulk_insert`. See https://docs.gitlab.com/ee/development/insert_into_tables_in_batches.html
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{RuboCop::Cop::Gitlab::BulkInsert::MSG}
+ SOURCE
+ end
+
+ it 'flags the use of ::Gitlab::Database.bulk_insert' do
+ expect_offense(<<~SOURCE)
+ ::Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{RuboCop::Cop::Gitlab::BulkInsert::MSG}
SOURCE
end
end
diff --git a/spec/rubocop/cop/gitlab/except_spec.rb b/spec/rubocop/cop/gitlab/except_spec.rb
new file mode 100644
index 00000000000..50277d15a57
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/except_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../../rubocop/cop/gitlab/except'
+
+RSpec.describe RuboCop::Cop::Gitlab::Except, type: :rubocop do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ it 'flags the use of Gitlab::SQL::Except.new' do
+ expect_offense(<<~SOURCE)
+ Gitlab::SQL::Except.new([foo])
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use the `FromExcept` concern, instead of using `Gitlab::SQL::Except` directly
+ SOURCE
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/intersect_spec.rb b/spec/rubocop/cop/gitlab/intersect_spec.rb
new file mode 100644
index 00000000000..351033d0ed2
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/intersect_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../../rubocop/cop/gitlab/intersect'
+
+RSpec.describe RuboCop::Cop::Gitlab::Intersect, type: :rubocop do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ it 'flags the use of Gitlab::SQL::Intersect.new' do
+ expect_offense(<<~SOURCE)
+ Gitlab::SQL::Intersect.new([foo])
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use the `FromIntersect` concern, instead of using `Gitlab::SQL::Intersect` directly
+ SOURCE
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/rails_logger_spec.rb b/spec/rubocop/cop/gitlab/rails_logger_spec.rb
index 0583079136b..70d208b31ec 100644
--- a/spec/rubocop/cop/gitlab/rails_logger_spec.rb
+++ b/spec/rubocop/cop/gitlab/rails_logger_spec.rb
@@ -10,22 +10,12 @@ RSpec.describe RuboCop::Cop::Gitlab::RailsLogger, type: :rubocop do
subject(:cop) { described_class.new }
- it 'flags the use of Rails.logger.error with a constant receiver' do
- inspect_source("Rails.logger.error('some error')")
+ described_class::LOG_METHODS.each do |method|
+ it "flags the use of Rails.logger.#{method} with a constant receiver" do
+ inspect_source("Rails.logger.#{method}('some error')")
- expect(cop.offenses.size).to eq(1)
- end
-
- it 'flags the use of Rails.logger.info with a constant receiver' do
- inspect_source("Rails.logger.info('some info')")
-
- expect(cop.offenses.size).to eq(1)
- end
-
- it 'flags the use of Rails.logger.warn with a constant receiver' do
- inspect_source("Rails.logger.warn('some warning')")
-
- expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.size).to eq(1)
+ end
end
it 'does not flag the use of Rails.logger with a constant that is not Rails' do
@@ -39,4 +29,10 @@ RSpec.describe RuboCop::Cop::Gitlab::RailsLogger, type: :rubocop do
expect(cop.offenses.size).to eq(0)
end
+
+ it 'does not flag the use of Rails.logger.level' do
+ inspect_source("Rails.logger.level")
+
+ expect(cop.offenses.size).to eq(0)
+ end
end
diff --git a/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb b/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb
new file mode 100644
index 00000000000..b769109057e
--- /dev/null
+++ b/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+#
+require 'fast_spec_helper'
+require 'rubocop'
+require_relative '../../../../rubocop/cop/migration/complex_indexes_require_name'
+
+RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName, type: :rubocop do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ context 'when creating complex indexes as part of create_table' do
+ context 'when indexes are configured with an options hash, but no name' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ class TestComplexIndexes < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ create_table :test_table do |t|
+ t.integer :column1, null: false
+ t.integer :column2, null: false
+ t.jsonb :column3
+
+ t.index :column1, unique: true
+ t.index :column2, where: 'column1 = 0'
+ ^^^^^ #{described_class::MSG}
+ t.index :column3, using: :gin
+ ^^^^^ #{described_class::MSG}
+ end
+ end
+
+ def down
+ drop_table :test_table
+ end
+ end
+ RUBY
+
+ expect(cop.offenses.map(&:cop_name)).to all(eq("Migration/#{described_class.name.demodulize}"))
+ end
+ end
+
+ context 'when indexes are configured with an options hash and name' do
+ it 'registers no offense' do
+ expect_no_offenses(<<~RUBY)
+ class TestComplexIndexes < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ create_table :test_table do |t|
+ t.integer :column1, null: false
+ t.integer :column2, null: false
+ t.jsonb :column3
+
+ t.index :column1, unique: true
+ t.index :column2, where: 'column1 = 0', name: 'my_index_1'
+ t.index :column3, using: :gin, name: 'my_gin_index'
+ end
+ end
+
+ def down
+ drop_table :test_table
+ end
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'when indexes are added to an existing table' do
+ context 'when indexes are configured with an options hash, but no name' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ class TestComplexIndexes < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_index :test_indexes, :column1
+
+ add_index :test_indexes, :column2, where: "column2 = 'value'", order: { column4: :desc }
+ ^^^^^^^^^ #{described_class::MSG}
+ end
+
+ def down
+ add_index :test_indexes, :column4, 'unique' => true, where: 'column4 IS NOT NULL'
+ ^^^^^^^^^ #{described_class::MSG}
+
+ add_concurrent_index :test_indexes, :column6, using: :gin, opclass: :gin_trgm_ops
+ ^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ end
+ end
+ RUBY
+
+ expect(cop.offenses.map(&:cop_name)).to all(eq("Migration/#{described_class.name.demodulize}"))
+ end
+ end
+
+ context 'when indexes are configured with an options hash and a name' do
+ it 'registers no offenses' do
+ expect_no_offenses(<<~RUBY)
+ class TestComplexIndexes < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ INDEX_NAME = 'my_test_name'
+
+ disable_ddl_transaction!
+
+ def up
+ add_index :test_indexes, :column1
+
+ add_index :test_indexes, :column2, where: "column2 = 'value'", order: { column4: :desc }, name: 'my_index_1'
+
+ add_concurrent_index :test_indexes, :column3, where: 'column3 = 10', name: 'idx_equal_to_10'
+ end
+
+ def down
+ add_index :test_indexes, :column4, 'unique' => true, where: 'column4 IS NOT NULL', name: 'my_index_2'
+
+ add_concurrent_index :test_indexes, :column6, using: :gin, opclass: :gin_trgm_ops, name: INDEX_NAME
+ end
+ end
+ RUBY
+ end
+ end
+ end
+ end
+
+ context 'outside migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(false)
+ end
+
+ it 'registers no offenses' do
+ expect_no_offenses(<<~RUBY)
+ class TestComplexIndexes < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ create_table :test_table do |t|
+ t.integer :column1
+
+ t.index :column1, where: 'column2 IS NOT NULL'
+ end
+
+ add_index :test_indexes, :column1, where: "some_column = 'value'"
+ end
+
+ def down
+ add_concurrent_index :test_indexes, :column2, unique: true
+ end
+ end
+ RUBY
+ end
+ end
+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
new file mode 100644
index 00000000000..fa4acc62226
--- /dev/null
+++ b/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb
@@ -0,0 +1,205 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rubocop'
+require_relative '../../../../rubocop/cop/migration/create_table_with_foreign_keys'
+
+RSpec.describe RuboCop::Cop::Migration::CreateTableWithForeignKeys, type: :rubocop do
+ include CopHelper
+
+ let(:cop) { described_class.new }
+
+ context 'outside of a migration' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ def up
+ create_table(:foo) do |t|
+ t.references :bar, foreign_key: { on_delete: 'cascade' }
+ t.references :zoo, foreign_key: { on_delete: 'cascade' }
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'in a migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ context 'without foreign key' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ def up
+ create_table(:foo) do |t|
+ t.references :bar
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'with foreign key' do
+ context 'with just one foreign key' do
+ context 'when the foreign_key targets a high traffic table' do
+ context 'when the foreign_key has to_table option set' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ def up
+ create_table(:foo) do |t|
+ t.references :project, "foreign_key" => { on_delete: 'cascade', to_table: 'projects' }
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'when the foreign_key does not have to_table option set' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ def up
+ create_table(:foo) do |t|
+ t.references :project, foreign_key: { on_delete: 'cascade' }
+ end
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'when the foreign_key does not target a high traffic table' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ def up
+ create_table(:foo) do |t|
+ t.references :bar, foreign_key: { on_delete: 'cascade' }
+ end
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'with more than one foreign keys' do
+ let(:offense) do
+ 'Creating a table with more than one foreign key at once violates our migration style guide. ' \
+ 'For more details check the https://docs.gitlab.com/ce/development/migration_style_guide.html#examples'
+ end
+
+ shared_examples 'target to high traffic table' do |dsl_method, table_name|
+ context 'when the target is defined as option' do
+ it 'registers an offense ' do
+ expect_offense(<<~RUBY)
+ def up
+ create_table(:foo) do |t|
+ ^^^^^^^^^^^^^^^^^^ #{offense}
+ t.#{dsl_method} :#{table_name.singularize} #{explicit_target_opts}
+ t.#{dsl_method} :zoo #{implicit_target_opts}
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'when the target has implicit definition' do
+ it 'registers an offense ' do
+ expect_offense(<<~RUBY)
+ def up
+ create_table(:foo) do |t|
+ ^^^^^^^^^^^^^^^^^^ #{offense}
+ t.#{dsl_method} :#{table_name.singularize} #{implicit_target_opts}
+ t.#{dsl_method} :zoo #{implicit_target_opts}
+ end
+ end
+ RUBY
+ end
+ end
+ end
+
+ shared_context 'when there is a target to a high traffic table' do |dsl_method|
+ %w[
+ audit_events
+ ci_build_trace_sections
+ ci_builds
+ ci_builds_metadata
+ ci_job_artifacts
+ ci_pipeline_variables
+ ci_pipelines
+ ci_stages
+ deployments
+ events
+ gitlab_subscriptions
+ issues
+ merge_request_diff_commits
+ merge_request_diff_files
+ merge_request_diffs
+ merge_request_metrics
+ merge_requests
+ namespaces
+ note_diff_files
+ notes
+ project_authorizations
+ projects
+ project_ci_cd_settings
+ project_features
+ push_event_payloads
+ resource_label_events
+ routes
+ sent_notifications
+ system_note_metadata
+ taggings
+ todos
+ users
+ web_hook_logs
+ ].each do |table|
+ let(:table_name) { table }
+
+ it_behaves_like 'target to high traffic table', dsl_method, table
+ end
+ end
+
+ context 'when the foreign keys are defined as options' do
+ context 'when there is no target to a high traffic table' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ def up
+ create_table(:foo) do |t|
+ t.references :bar, foreign_key: { on_delete: 'cascade', to_table: :bars }
+ t.references :zoo, 'foreign_key' => { on_delete: 'cascade' }
+ t.references :john, 'foreign_key' => { on_delete: 'cascade' }
+ t.references :doe, 'foreign_key' => { on_delete: 'cascade', to_table: 'doe' }
+ end
+ end
+ RUBY
+ end
+ end
+
+ include_context 'when there is a target to a high traffic table', :references do
+ let(:explicit_target_opts) { ", foreign_key: { to_table: :#{table_name} }" }
+ let(:implicit_target_opts) { ", foreign_key: true" }
+ end
+ end
+
+ context 'when the foreign keys are defined by standlone migration helper' do
+ context 'when there is no target to a high traffic table' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ def up
+ create_table(:foo) do |t|
+ t.foreign_key :bar
+ t.foreign_key :zoo, to_table: 'zoos'
+ end
+ end
+ RUBY
+ end
+ end
+
+ include_context 'when there is a target to a high traffic table', :foreign_key do
+ let(:explicit_target_opts) { ", to_table: :#{table_name}" }
+ let(:implicit_target_opts) { }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb b/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb
new file mode 100644
index 00000000000..76554d7446c
--- /dev/null
+++ b/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+#
+require 'fast_spec_helper'
+require 'rubocop'
+require_relative '../../../../rubocop/cop/migration/refer_to_index_by_name'
+
+RSpec.describe RuboCop::Cop::Migration::ReferToIndexByName, type: :rubocop do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ context 'when existing indexes are referred to without an explicit name' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ class TestReferToIndexByName < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ INDEX_NAME = 'my_test_name'
+
+ disable_ddl_transaction!
+
+ def up
+ if index_exists? :test_indexes, :column1, name: 'index_name_1'
+ remove_index :test_indexes, column: :column1, name: 'index_name_1'
+ end
+
+ if index_exists? :test_indexes, :column2
+ ^^^^^^^^^^^^^ #{described_class::MSG}
+ remove_index :test_indexes, :column2
+ ^^^^^^^^^^^^ #{described_class::MSG}
+ end
+
+ remove_index :test_indexes, column: column3
+ ^^^^^^^^^^^^ #{described_class::MSG}
+
+ remove_index :test_indexes, name: 'index_name_4'
+ end
+
+ def down
+ if index_exists? :test_indexes, :column4, using: :gin, opclass: :gin_trgm_ops
+ ^^^^^^^^^^^^^ #{described_class::MSG}
+ remove_concurrent_index :test_indexes, :column4, using: :gin, opclass: :gin_trgm_ops
+ ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ end
+
+ if index_exists? :test_indexes, :column3, unique: true, name: 'index_name_3', where: 'column3 = 10'
+ remove_concurrent_index :test_indexes, :column3, unique: true, name: 'index_name_3', where: 'column3 = 10'
+ end
+ end
+ end
+ RUBY
+
+ expect(cop.offenses.map(&:cop_name)).to all(eq("Migration/#{described_class.name.demodulize}"))
+ end
+ end
+ end
+
+ context 'outside migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(false)
+ end
+
+ it 'registers no offenses' do
+ expect_no_offenses(<<~RUBY)
+ class TestReferToIndexByName < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ if index_exists? :test_indexes, :column1
+ remove_index :test_indexes, :column1
+ end
+ end
+
+ def down
+ if index_exists? :test_indexes, :column1
+ remove_concurrent_index :test_indexes, :column1
+ end
+ end
+ end
+ RUBY
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
index 013f2edc5e9..72b817fde12 100644
--- a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
+++ b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn, type: :rubocop do
allow(cop).to receive(:in_migration?).and_return(true)
end
- described_class::WHITELISTED_TABLES.each do |table|
+ described_class::SMALL_TABLES.each do |table|
context "for the #{table} table" do
sources_and_offense = [
["add_column :#{table}, :column, :boolean, default: true", 'should disallow nulls'],
@@ -59,14 +59,14 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn, type: :rubocop do
end
end
- it 'registers no offense for tables not listed in WHITELISTED_TABLES' do
+ it 'registers no offense for tables not listed in SMALL_TABLES' do
inspect_source("add_column :large_table, :column, :boolean")
expect(cop.offenses).to be_empty
end
it 'registers no offense for non-boolean columns' do
- table = described_class::WHITELISTED_TABLES.sample
+ table = described_class::SMALL_TABLES.sample
inspect_source("add_column :#{table}, :column, :string")
expect(cop.offenses).to be_empty
@@ -75,7 +75,7 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn, type: :rubocop do
context 'outside of migration' do
it 'registers no offense' do
- table = described_class::WHITELISTED_TABLES.sample
+ table = described_class::SMALL_TABLES.sample
inspect_source("add_column :#{table}, :column, :boolean")
expect(cop.offenses).to be_empty
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 5d96e8048bf..1d50d8c675e 100644
--- a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
+++ b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
@@ -1,15 +1,15 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'fast_spec_helper'
require 'rubocop'
require 'rubocop/rspec/support'
require_relative '../../../../rubocop/cop/migration/update_column_in_batches'
-RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
+RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches, type: :rubocop do
let(:cop) { described_class.new }
- let(:tmp_rails_root) { Rails.root.join('tmp', 'rails_root') }
+ let(:tmp_rails_root) { rails_root_join('tmp', 'rails_root') }
let(:migration_code) do
<<-END
def up
@@ -27,6 +27,8 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
FileUtils.rm_rf(tmp_rails_root)
end
+ let(:spec_filepath) { File.join(tmp_rails_root, 'spec', 'migrations', 'my_super_migration_spec.rb') }
+
context 'outside of a migration' do
it 'does not register any offenses' do
inspect_source(migration_code)
@@ -35,8 +37,6 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
end
end
- let(:spec_filepath) { tmp_rails_root.join('spec', 'migrations', 'my_super_migration_spec.rb') }
-
shared_context 'with a migration file' do
before do
FileUtils.mkdir_p(File.dirname(migration_filepath))
@@ -83,31 +83,31 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
end
context 'in a migration' do
- let(:migration_filepath) { tmp_rails_root.join('db', 'migrate', '20121220064453_my_super_migration.rb') }
+ let(:migration_filepath) { File.join(tmp_rails_root, 'db', 'migrate', '20121220064453_my_super_migration.rb') }
it_behaves_like 'a migration file with no spec file'
it_behaves_like 'a migration file with a spec file'
end
context 'in a post migration' do
- let(:migration_filepath) { tmp_rails_root.join('db', 'post_migrate', '20121220064453_my_super_migration.rb') }
+ let(:migration_filepath) { File.join(tmp_rails_root, 'db', 'post_migrate', '20121220064453_my_super_migration.rb') }
it_behaves_like 'a migration file with no spec file'
it_behaves_like 'a migration file with a spec file'
end
context 'EE migrations' do
- let(:spec_filepath) { tmp_rails_root.join('ee', 'spec', 'migrations', 'my_super_migration_spec.rb') }
+ let(:spec_filepath) { File.join(tmp_rails_root, 'ee', 'spec', 'migrations', 'my_super_migration_spec.rb') }
context 'in a migration' do
- let(:migration_filepath) { tmp_rails_root.join('ee', 'db', 'migrate', '20121220064453_my_super_migration.rb') }
+ let(:migration_filepath) { File.join(tmp_rails_root, 'ee', 'db', 'migrate', '20121220064453_my_super_migration.rb') }
it_behaves_like 'a migration file with no spec file'
it_behaves_like 'a migration file with a spec file'
end
context 'in a post migration' do
- let(:migration_filepath) { tmp_rails_root.join('ee', 'db', 'post_migrate', '20121220064453_my_super_migration.rb') }
+ let(:migration_filepath) { File.join(tmp_rails_root, 'ee', 'db', 'post_migrate', '20121220064453_my_super_migration.rb') }
it_behaves_like 'a migration file with no spec file'
it_behaves_like 'a migration file with a spec file'
diff --git a/spec/rubocop/cop/put_group_routes_under_scope_spec.rb b/spec/rubocop/cop/put_group_routes_under_scope_spec.rb
index c55d9bf22d6..888d1b6a2ba 100644
--- a/spec/rubocop/cop/put_group_routes_under_scope_spec.rb
+++ b/spec/rubocop/cop/put_group_routes_under_scope_spec.rb
@@ -46,4 +46,18 @@ RSpec.describe RuboCop::Cop::PutGroupRoutesUnderScope, type: :rubocop do
end
PATTERN
end
+
+ it 'does not register an offense for the root route' do
+ expect_no_offenses(<<~PATTERN)
+ get '/'
+ PATTERN
+ end
+
+ it 'does not register an offense for the root route within scope' do
+ expect_no_offenses(<<~PATTERN)
+ scope(path: 'groups/*group_id/-', module: :groups) do
+ get '/'
+ end
+ PATTERN
+ end
end
diff --git a/spec/rubocop/cop/put_project_routes_under_scope_spec.rb b/spec/rubocop/cop/put_project_routes_under_scope_spec.rb
index 05e1cd7b693..eebb7f3eb61 100644
--- a/spec/rubocop/cop/put_project_routes_under_scope_spec.rb
+++ b/spec/rubocop/cop/put_project_routes_under_scope_spec.rb
@@ -46,4 +46,18 @@ RSpec.describe RuboCop::Cop::PutProjectRoutesUnderScope, type: :rubocop do
end
PATTERN
end
+
+ it 'does not register an offense for the root route' do
+ expect_no_offenses(<<~PATTERN)
+ get '/'
+ PATTERN
+ end
+
+ it 'does not register an offense for the root route within scope' do
+ expect_no_offenses(<<~PATTERN)
+ scope '-' do
+ get '/'
+ end
+ PATTERN
+ end
end
diff --git a/spec/rubocop/cop/rspec/timecop_freeze_spec.rb b/spec/rubocop/cop/rspec/timecop_freeze_spec.rb
new file mode 100644
index 00000000000..3809431a2fc
--- /dev/null
+++ b/spec/rubocop/cop/rspec/timecop_freeze_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/rspec/timecop_freeze'
+
+RSpec.describe RuboCop::Cop::RSpec::TimecopFreeze, type: :rubocop do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'when calling Timecop.freeze' do
+ let(:source) do
+ <<~SRC
+ Timecop.freeze(Time.current) { example.run }
+ SRC
+ end
+
+ let(:corrected_source) do
+ <<~SRC
+ freeze_time(Time.current) { example.run }
+ SRC
+ end
+
+ it 'registers an offence' do
+ inspect_source(source)
+
+ expect(cop.offenses.size).to eq(1)
+ end
+
+ it 'can autocorrect the source' do
+ expect(autocorrect_source(source)).to eq(corrected_source)
+ end
+ end
+
+ context 'when calling a different method on Timecop' do
+ let(:source) do
+ <<~SRC
+ Timecop.travel(Time.current)
+ SRC
+ end
+
+ it 'does not register an offence' do
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+end
diff --git a/spec/rubocop/cop/static_translation_definition_spec.rb b/spec/rubocop/cop/static_translation_definition_spec.rb
index b6c9f6a25df..f3185def3d7 100644
--- a/spec/rubocop/cop/static_translation_definition_spec.rb
+++ b/spec/rubocop/cop/static_translation_definition_spec.rb
@@ -40,6 +40,17 @@ RSpec.describe RuboCop::Cop::StaticTranslationDefinition, type: :rubocop do
['C = n_("c")', 'n_("c")', 1],
[
<<~CODE,
+ class MyClass
+ def self.translations
+ @cache ||= { hello: _("hello") }
+ end
+ end
+ CODE
+ '_("hello")',
+ 3
+ ],
+ [
+ <<~CODE,
module MyModule
A = {
b: {
@@ -79,6 +90,20 @@ RSpec.describe RuboCop::Cop::StaticTranslationDefinition, type: :rubocop do
'CONSTANT_2 = s__("a")',
'CONSTANT_3 = n__("a")',
<<~CODE,
+ class MyClass
+ def self.method
+ @cache ||= { hello: -> { _("hello") } }
+ end
+ end
+ CODE
+ <<~CODE,
+ class MyClass
+ def method
+ @cache ||= { hello: _("hello") }
+ end
+ end
+ CODE
+ <<~CODE,
def method
s_('a')
end
diff --git a/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb b/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb
index db931c50bdf..8b6a2eac349 100644
--- a/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb
+++ b/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb
@@ -10,7 +10,7 @@ require_relative '../../../../rubocop/cop/usage_data/distinct_count_by_large_for
RSpec.describe RuboCop::Cop::UsageData::DistinctCountByLargeForeignKey, type: :rubocop do
include CopHelper
- let(:allowed_foreign_keys) { %i[author_id user_id] }
+ let(:allowed_foreign_keys) { [:author_id, :user_id, :'merge_requests.target_project_id'] }
let(:config) do
RuboCop::Config.new('UsageData/DistinctCountByLargeForeignKey' => {
@@ -21,18 +21,36 @@ RSpec.describe RuboCop::Cop::UsageData::DistinctCountByLargeForeignKey, type: :r
subject(:cop) { described_class.new(config) }
context 'when counting by disallowed key' do
- it 'register an offence' do
+ it 'registers an offence' do
inspect_source('distinct_count(Issue, :creator_id)')
expect(cop.offenses.size).to eq(1)
end
+
+ it 'does not register an offence when batch is false' do
+ inspect_source('distinct_count(Issue, :creator_id, batch: false)')
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it 'register an offence when batch is true' do
+ inspect_source('distinct_count(Issue, :creator_id, batch: true)')
+
+ expect(cop.offenses.size).to eq(1)
+ end
end
context 'when calling by allowed key' do
- it 'does not register an offence' do
+ it 'does not register an offence with symbol' do
inspect_source('distinct_count(Issue, :author_id)')
expect(cop.offenses).to be_empty
end
+
+ it 'does not register an offence with string' do
+ inspect_source("distinct_count(Issue, 'merge_requests.target_project_id')")
+
+ expect(cop.offenses).to be_empty
+ end
end
end
diff --git a/spec/serializers/analytics_build_entity_spec.rb b/spec/serializers/analytics_build_entity_spec.rb
index 20bd017d1cf..09804681f5d 100644
--- a/spec/serializers/analytics_build_entity_spec.rb
+++ b/spec/serializers/analytics_build_entity_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe AnalyticsBuildEntity do
subject { entity.as_json }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
it 'contains the URL' do
diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb
index 3166c08ff4e..5d29452e91c 100644
--- a/spec/serializers/build_details_entity_spec.rb
+++ b/spec/serializers/build_details_entity_spec.rb
@@ -188,25 +188,31 @@ RSpec.describe BuildDetailsEntity do
context 'when the build has expired artifacts' do
let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: 7.days.ago) }
- it 'does not expose any artifact actions path' do
- expect(subject[:artifact].keys).not_to include(:download_path, :browse_path, :keep_path)
- end
+ context 'when pipeline is unlocked' do
+ before do
+ build.pipeline.unlocked!
+ end
+
+ it 'artifact locked is false' do
+ expect(subject.dig(:artifact, :locked)).to eq(false)
+ end
- it 'artifact locked is false' do
- expect(subject.dig(:artifact, :locked)).to eq(false)
+ it 'does not expose any artifact actions path' do
+ expect(subject[:artifact].keys).not_to include(:download_path, :browse_path, :keep_path)
+ end
end
context 'when the pipeline is artifacts_locked' do
before do
- build.pipeline.update!(locked: :artifacts_locked)
+ build.pipeline.artifacts_locked!
end
it 'artifact locked is true' do
expect(subject.dig(:artifact, :locked)).to eq(true)
end
- it 'exposes download and browse artifact actions path' do
- expect(subject[:artifact].keys).to include(:download_path, :browse_path)
+ it 'exposes download, browse and keep artifact actions path' do
+ expect(subject[:artifact].keys).to include(:download_path, :browse_path, :keep_path)
end
end
end
diff --git a/spec/serializers/ci/lint/job_entity_spec.rb b/spec/serializers/ci/lint/job_entity_spec.rb
new file mode 100644
index 00000000000..2ef86cfd004
--- /dev/null
+++ b/spec/serializers/ci/lint/job_entity_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::Lint::JobEntity, :aggregate_failures do
+ describe '#represent' do
+ let(:job) do
+ {
+ name: 'rspec',
+ stage: 'test',
+ before_script: ['bundle install', 'bundle exec rake db:create'],
+ script: ["rake spec"],
+ after_script: ["rake spec"],
+ tag_list: %w[ruby postgres],
+ environment: { name: 'hello', url: 'world' },
+ when: 'on_success',
+ allow_failure: false,
+ except: { refs: ["branches"] },
+ only: { refs: ["branches"] },
+ variables: { hello: 'world' }
+ }
+ end
+
+ subject(:serialized_job_result) { described_class.new(job).as_json }
+
+ it 'exposes job data' do
+ expect(serialized_job_result.keys).to contain_exactly(
+ :name,
+ :stage,
+ :before_script,
+ :script,
+ :after_script,
+ :tag_list,
+ :environment,
+ :when,
+ :allow_failure,
+ :only,
+ :except
+ )
+ expect(serialized_job_result.keys).not_to include(:variables)
+ end
+ end
+end
diff --git a/spec/serializers/ci/lint/result_entity_spec.rb b/spec/serializers/ci/lint/result_entity_spec.rb
new file mode 100644
index 00000000000..7b5465a3649
--- /dev/null
+++ b/spec/serializers/ci/lint/result_entity_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::Lint::ResultEntity do
+ describe '#represent' do
+ let(:yaml_content) { YAML.dump({ rspec: { script: 'test', tags: 'mysql' } }) }
+ let(:result) { Gitlab::Ci::YamlProcessor.new(yaml_content).execute }
+
+ subject(:serialized_linting_result) { described_class.new(result).as_json }
+
+ it 'serializes with lint result entity' do
+ expect(serialized_linting_result.keys).to include(:valid, :errors, :jobs, :warnings)
+ end
+ end
+end
diff --git a/spec/serializers/ci/lint/result_serializer_spec.rb b/spec/serializers/ci/lint/result_serializer_spec.rb
new file mode 100644
index 00000000000..7aa95a574bf
--- /dev/null
+++ b/spec/serializers/ci/lint/result_serializer_spec.rb
@@ -0,0 +1,261 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::Lint::ResultSerializer, :aggregate_failures do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:result) do
+ Gitlab::Ci::Lint
+ .new(project: project, current_user: project.owner)
+ .validate(yaml_content, dry_run: false)
+ end
+
+ let(:first_job) { linting_result[:jobs].first }
+ let(:serialized_linting_result) { linting_result.to_json }
+
+ subject(:linting_result) { described_class.new.represent(result) }
+
+ shared_examples 'matches schema' do
+ it { expect(serialized_linting_result).to match_schema('entities/lint_result_entity') }
+ end
+
+ context 'when config is invalid' do
+ let(:yaml_content) { YAML.dump({ rspec: { script: 'test', tags: 'mysql' } }) }
+
+ it_behaves_like 'matches schema'
+
+ it 'returns expected validity' do
+ expect(linting_result[:valid]).to eq(false)
+ expect(linting_result[:errors]).to eq(['jobs:rspec:tags config should be an array of strings'])
+ expect(linting_result[:warnings]).to eq([])
+ end
+
+ it 'returns job data' do
+ expect(linting_result[:jobs]).to eq([])
+ end
+ end
+
+ context 'when config is valid' do
+ let(:yaml_content) { File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) }
+
+ it_behaves_like 'matches schema'
+
+ it 'returns expected validity' do
+ expect(linting_result[:valid]).to eq(true)
+ expect(linting_result[:errors]).to eq([])
+ expect(linting_result[:warnings]).to eq([])
+ end
+
+ it 'returns job data' do
+ expect(first_job[:name]).to eq('rspec')
+ expect(first_job[:stage]).to eq('test')
+ expect(first_job[:before_script]).to eq(['bundle install', 'bundle exec rake db:create'])
+ expect(first_job[:script]).to eq(['rake spec'])
+ expect(first_job[:after_script]).to eq([])
+ expect(first_job[:tag_list]).to eq(%w[ruby postgres])
+ expect(first_job[:environment]).to eq(nil)
+ expect(first_job[:when]).to eq('on_success')
+ expect(first_job[:allow_failure]).to eq(false)
+ expect(first_job[:only]).to eq(refs: ['branches'])
+ expect(first_job[:except]).to eq(nil)
+ end
+
+ context 'when dry run is enabled' do
+ let(:result) do
+ Gitlab::Ci::Lint
+ .new(project: project, current_user: project.owner)
+ .validate(yaml_content, dry_run: true)
+ end
+
+ it_behaves_like 'matches schema'
+
+ it 'returns expected validity' do
+ expect(linting_result[:valid]).to eq(true)
+ expect(linting_result[:errors]).to eq([])
+ expect(linting_result[:warnings]).to eq([])
+ end
+
+ it 'returns job data' do
+ expect(first_job[:name]).to eq('rspec')
+ expect(first_job[:stage]).to eq('test')
+ expect(first_job[:before_script]).to eq(['bundle install', 'bundle exec rake db:create'])
+ expect(first_job[:script]).to eq(['rake spec'])
+ expect(first_job[:after_script]).to eq([])
+ expect(first_job[:tag_list]).to eq(%w[ruby postgres])
+ expect(first_job[:environment]).to eq(nil)
+ expect(first_job[:when]).to eq('on_success')
+ expect(first_job[:allow_failure]).to eq(false)
+ expect(first_job[:only]).to eq(nil)
+ expect(first_job[:except]).to eq(nil)
+ end
+ end
+
+ context 'when only is not nil in the yaml' do
+ context 'when only: is hash' do
+ let(:yaml_content) do
+ <<~YAML
+ build:
+ stage: build
+ script: echo
+ only:
+ refs:
+ - branches
+ YAML
+ end
+
+ it_behaves_like 'matches schema'
+
+ it 'renders only:refs as hash' do
+ expect(first_job[:only]).to eq(refs: ['branches'])
+ end
+ end
+
+ context 'when only is an array of strings in the yaml' do
+ let(:yaml_content) do
+ <<~YAML
+ build:
+ stage: build
+ script: echo
+ only:
+ - pushes
+ YAML
+ end
+
+ it_behaves_like 'matches schema'
+
+ it 'renders only: list as hash' do
+ expect(first_job[:only]).to eq(refs: ['pushes'])
+ end
+ end
+ end
+
+ context 'when except is not nil in the yaml' do
+ context 'when except: is hash' do
+ let(:yaml_content) do
+ <<~YAML
+ build:
+ stage: build
+ script: echo
+ except:
+ refs:
+ - branches
+ YAML
+ end
+
+ it_behaves_like 'matches schema'
+
+ it 'renders except as hash' do
+ expect(first_job[:except]).to eq(refs: ['branches'])
+ end
+ end
+
+ context 'when except is an array of strings in the yaml' do
+ let(:yaml_content) do
+ <<~YAML
+ build:
+ stage: build
+ script: echo
+ except:
+ - pushes
+ YAML
+ end
+
+ it_behaves_like 'matches schema'
+
+ it 'renders only: list as hash' do
+ expect(first_job[:except]).to eq(refs: ['pushes'])
+ end
+ end
+
+ context 'with minimal job configuration' do
+ let(:yaml_content) do
+ <<~YAML
+ build:
+ stage: build
+ script: echo
+ YAML
+ end
+
+ it_behaves_like 'matches schema'
+
+ it 'renders the job with defaults' do
+ expect(first_job[:name]).to eq('build')
+ expect(first_job[:stage]).to eq('build')
+ expect(first_job[:before_script]).to eq([])
+ expect(first_job[:script]).to eq(['echo'])
+ expect(first_job[:after_script]).to eq([])
+ expect(first_job[:tag_list]).to eq([])
+ expect(first_job[:environment]).to eq(nil)
+ expect(first_job[:when]).to eq('on_success')
+ expect(first_job[:allow_failure]).to eq(false)
+ expect(first_job[:only]).to eq(refs: %w[branches tags])
+ expect(first_job[:except]).to eq(nil)
+ end
+ end
+
+ context 'with environment defined' do
+ context 'when formatted as a hash in yaml' do
+ let(:yaml_content) do
+ <<~YAML
+ build:
+ stage: build
+ script: echo
+ environment:
+ name: production
+ url: https://example.com
+ YAML
+ end
+
+ it_behaves_like 'matches schema'
+
+ it 'renders the environment as a string' do
+ expect(first_job[:environment]).to eq('production')
+ end
+ end
+
+ context 'when formatted as a string in yaml' do
+ let(:yaml_content) do
+ <<~YAML
+ build:
+ stage: build
+ script: echo
+ environment: production
+ YAML
+ end
+
+ it_behaves_like 'matches schema'
+
+ it 'renders the environment as a string' do
+ expect(first_job[:environment]).to eq('production')
+ end
+ end
+ end
+
+ context 'when script values are formatted as arrays in the yaml' do
+ let(:yaml_content) do
+ <<~YAML
+ build:
+ stage: build
+ before_script:
+ - echo
+ - cat '~/.zshrc'
+ script:
+ - echo
+ - cat '~/.zshrc'
+ after_script:
+ - echo
+ - cat '~/.zshrc'
+ YAML
+ end
+
+ it_behaves_like 'matches schema'
+
+ it 'renders the scripts as arrays' do
+ expect(first_job[:before_script]).to eq(['echo', "cat '~/.zshrc'"])
+ expect(first_job[:script]).to eq(['echo', "cat '~/.zshrc'"])
+ expect(first_job[:after_script]).to eq(['echo', "cat '~/.zshrc'"])
+ end
+ end
+ end
+ end
+end
diff --git a/spec/serializers/cluster_entity_spec.rb b/spec/serializers/cluster_entity_spec.rb
index 223d37b6acd..10c6bc0e42a 100644
--- a/spec/serializers/cluster_entity_spec.rb
+++ b/spec/serializers/cluster_entity_spec.rb
@@ -78,5 +78,26 @@ RSpec.describe ClusterEntity do
expect(subject[:gitlab_managed_apps_logs_path]).to eq(log_explorer_path)
end
end
+
+ context 'enable_advanced_logs_querying' do
+ let(:cluster) { create(:cluster, :project) }
+ let(:user) { create(:user) }
+
+ subject { described_class.new(cluster, request: request).as_json }
+
+ context 'elastic stack is not installed on cluster' do
+ it 'returns false' do
+ expect(subject[:enable_advanced_logs_querying]).to be false
+ end
+ end
+
+ context 'elastic stack is installed on cluster' do
+ it 'returns true' do
+ create(:clusters_applications_elastic_stack, :installed, cluster: cluster)
+
+ expect(subject[:enable_advanced_logs_querying]).to be true
+ end
+ end
+ end
end
end
diff --git a/spec/serializers/cluster_serializer_spec.rb b/spec/serializers/cluster_serializer_spec.rb
index f34409c3cfb..04999975276 100644
--- a/spec/serializers/cluster_serializer_spec.rb
+++ b/spec/serializers/cluster_serializer_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe ClusterSerializer do
:enabled,
:environment_scope,
:gitlab_managed_apps_logs_path,
+ :enable_advanced_logs_querying,
:kubernetes_errors,
:name,
:nodes,
diff --git a/spec/serializers/diff_file_base_entity_spec.rb b/spec/serializers/diff_file_base_entity_spec.rb
index bf69a50a072..94c39e11790 100644
--- a/spec/serializers/diff_file_base_entity_spec.rb
+++ b/spec/serializers/diff_file_base_entity_spec.rb
@@ -7,21 +7,50 @@ RSpec.describe DiffFileBaseEntity do
let(:repository) { project.repository }
let(:entity) { described_class.new(diff_file, options).as_json }
- context 'diff for a changed submodule' do
- let(:commit_sha_with_changed_submodule) do
- "cfe32cf61b73a0d5e9f13e774abde7ff789b1660"
- end
-
- let(:commit) { project.commit(commit_sha_with_changed_submodule) }
+ context 'submodule information for a' do
+ let(:commit_sha) { "" }
+ let(:commit) { project.commit(commit_sha) }
let(:options) { { request: {}, submodule_links: Gitlab::SubmoduleLinks.new(repository) } }
let(:diff_file) { commit.diffs.diff_files.to_a.last }
- it do
- expect(entity[:submodule]).to eq(true)
- expect(entity[:submodule_link]).to eq("https://github.com/randx/six")
- expect(entity[:submodule_tree_url]).to eq(
- "https://github.com/randx/six/tree/409f37c4f05865e4fb208c771485f211a22c4c2d"
- )
+ context 'newly added submodule' do
+ let(:commit_sha) { "cfe32cf61b73a0d5e9f13e774abde7ff789b1660" }
+
+ it 'says it is a submodule and contains links' do
+ expect(entity[:submodule]).to eq(true)
+ expect(entity[:submodule_link]).to eq("https://github.com/randx/six")
+ expect(entity[:submodule_tree_url]).to eq(
+ "https://github.com/randx/six/tree/409f37c4f05865e4fb208c771485f211a22c4c2d"
+ )
+ end
+
+ it 'has no compare url because the submodule was newly added' do
+ expect(entity[:submodule_compare]).to eq(nil)
+ end
+ end
+
+ context 'changed submodule' do
+ # Test repo does not contain a commit that changes a submodule, so we have create such a commit here
+ let(:commit_sha) { repository.update_submodule(project.members[0].user, "six", "c6bc3aa2ee76cadaf0cbd325067c55450997632e", message: "Go one commit back", branch: "master") }
+
+ it 'contains a link to compare the changes' do
+ expect(entity[:submodule_compare]).to eq(
+ {
+ url: "https://github.com/randx/six/compare/409f37c4f05865e4fb208c771485f211a22c4c2d...c6bc3aa2ee76cadaf0cbd325067c55450997632e",
+ old_sha: "409f37c4f05865e4fb208c771485f211a22c4c2d",
+ new_sha: "c6bc3aa2ee76cadaf0cbd325067c55450997632e"
+ }
+ )
+ end
+ end
+
+ context 'normal file (no submodule)' do
+ let(:commit_sha) { '570e7b2abdd848b95f2f578043fc23bd6f6fd24d' }
+
+ it 'sets submodule to false' do
+ expect(entity[:submodule]).to eq(false)
+ expect(entity[:submodule_compare]).to eq(nil)
+ end
end
end
diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb
index c969638614e..c90f771335e 100644
--- a/spec/serializers/environment_entity_spec.rb
+++ b/spec/serializers/environment_entity_spec.rb
@@ -82,26 +82,6 @@ RSpec.describe EnvironmentEntity do
end
end
- context 'with alert' do
- let!(:environment) { create(:environment, project: project) }
- let!(:prometheus_alert) { create(:prometheus_alert, project: project, environment: environment) }
- let!(:alert) { create(:alert_management_alert, :triggered, :prometheus, project: project, environment: environment, prometheus_alert: prometheus_alert) }
-
- it 'exposes active alert flag' do
- project.add_maintainer(user)
-
- expect(subject[:has_opened_alert]).to eq(true)
- end
-
- context 'when user does not have permission to read alert' do
- it 'does not expose active alert flag' do
- project.add_reporter(user)
-
- expect(subject[:has_opened_alert]).to be_nil
- end
- end
- end
-
context 'pod_logs' do
context 'with reporter access' do
before do
diff --git a/spec/serializers/environment_status_entity_spec.rb b/spec/serializers/environment_status_entity_spec.rb
index a940c4b465e..77ef06f90c2 100644
--- a/spec/serializers/environment_status_entity_spec.rb
+++ b/spec/serializers/environment_status_entity_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe EnvironmentStatusEntity do
subject { entity.as_json }
before do
- deployment.update(sha: merge_request.diff_head_sha)
+ deployment.update!(sha: merge_request.diff_head_sha)
allow(request).to receive(:current_user).and_return(user)
end
diff --git a/spec/serializers/fork_namespace_entity_spec.rb b/spec/serializers/fork_namespace_entity_spec.rb
index 7ce6b77da44..7740ed77540 100644
--- a/spec/serializers/fork_namespace_entity_spec.rb
+++ b/spec/serializers/fork_namespace_entity_spec.rb
@@ -8,13 +8,17 @@ RSpec.describe ForkNamespaceEntity do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
+ let_it_be(:namespace) { create(:group, :with_avatar, description: 'test') }
+ let(:memberships) do
+ user.members.index_by(&:source_id)
+ end
- let(:namespace) { create(:group, :with_avatar, description: 'test') }
- let(:entity) { described_class.new(namespace, current_user: user, project: project) }
+ let(:entity) { described_class.new(namespace, current_user: user, project: project, memberships: memberships) }
subject(:json) { entity.as_json }
before do
+ namespace.add_developer(user)
project.add_maintainer(user)
end
@@ -52,7 +56,6 @@ RSpec.describe ForkNamespaceEntity do
end
it 'exposes human readable permission level' do
- namespace.add_developer(user)
expect(json[:permission]).to eql 'Developer'
end
diff --git a/spec/serializers/group_group_link_entity_spec.rb b/spec/serializers/group_group_link_entity_spec.rb
new file mode 100644
index 00000000000..8384563e3e6
--- /dev/null
+++ b/spec/serializers/group_group_link_entity_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GroupGroupLinkEntity do
+ include_context 'group_group_link'
+
+ subject(:json) { described_class.new(group_group_link).to_json }
+
+ it 'matches json schema' do
+ expect(json).to match_schema('entities/group_group_link')
+ end
+end
diff --git a/spec/serializers/group_group_link_serializer_spec.rb b/spec/serializers/group_group_link_serializer_spec.rb
new file mode 100644
index 00000000000..0d977ea0a9a
--- /dev/null
+++ b/spec/serializers/group_group_link_serializer_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GroupGroupLinkSerializer do
+ include_context 'group_group_link'
+
+ subject(:json) { described_class.new.represent(shared_group.shared_with_group_links).to_json }
+
+ it 'matches json schema' do
+ expect(json).to match_schema('group_group_links')
+ end
+end
diff --git a/spec/serializers/issue_entity_spec.rb b/spec/serializers/issue_entity_spec.rb
index 5c5ac184778..82ea26fae40 100644
--- a/spec/serializers/issue_entity_spec.rb
+++ b/spec/serializers/issue_entity_spec.rb
@@ -112,7 +112,7 @@ RSpec.describe IssueEntity do
context 'when project is archived' do
before do
- project.update(archived: true)
+ project.update!(archived: true)
end
it 'returns archived true' do
diff --git a/spec/serializers/issue_serializer_spec.rb b/spec/serializers/issue_serializer_spec.rb
index a51297d6d80..491e2f0835b 100644
--- a/spec/serializers/issue_serializer_spec.rb
+++ b/spec/serializers/issue_serializer_spec.rb
@@ -3,8 +3,9 @@
require 'spec_helper'
RSpec.describe IssueSerializer do
- let(:resource) { create(:issue) }
- let(:user) { create(:user) }
+ let_it_be(:resource) { create(:issue) }
+ let_it_be(:user) { create(:user) }
+
let(:json_entity) do
described_class.new(current_user: user)
.represent(resource, serializer: serializer)
diff --git a/spec/serializers/job_entity_spec.rb b/spec/serializers/job_entity_spec.rb
index 02262be9511..1cbf1914c0c 100644
--- a/spec/serializers/job_entity_spec.rb
+++ b/spec/serializers/job_entity_spec.rb
@@ -45,7 +45,7 @@ RSpec.describe JobEntity do
context 'when job is retryable' do
before do
- job.update(status: :failed)
+ job.update!(status: :failed)
end
it 'contains cancel path' do
@@ -55,7 +55,7 @@ RSpec.describe JobEntity do
context 'when job is cancelable' do
before do
- job.update(status: :running)
+ job.update!(status: :running)
end
it 'contains cancel path' do
@@ -218,4 +218,16 @@ RSpec.describe JobEntity do
expect(subject).not_to include('recoverable')
end
end
+
+ context 'when job is a bridge' do
+ let(:job) { create(:ci_bridge) }
+
+ it 'does not include build path' do
+ expect(subject).not_to include(:build_path)
+ end
+
+ it 'does not include cancel path' do
+ expect(subject).not_to include(:cancel_path)
+ end
+ end
end
diff --git a/spec/serializers/linked_project_issue_entity_spec.rb b/spec/serializers/linked_project_issue_entity_spec.rb
new file mode 100644
index 00000000000..864b5c45599
--- /dev/null
+++ b/spec/serializers/linked_project_issue_entity_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe LinkedProjectIssueEntity do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue_link) { create(:issue_link) }
+
+ let(:request) { double('request') }
+ let(:related_issue) { issue_link.source.related_issues(user).first }
+ let(:entity) { described_class.new(related_issue, request: request, current_user: user) }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ allow(request).to receive(:issuable).and_return(issue_link.source)
+ issue_link.target.project.add_developer(user)
+ end
+
+ describe 'issue_link_type' do
+ it { expect(entity.as_json).to include(link_type: 'relates_to') }
+ end
+end
diff --git a/spec/serializers/merge_request_basic_entity_spec.rb b/spec/serializers/merge_request_basic_entity_spec.rb
index 1cddd87e917..4a8bcd72d9c 100644
--- a/spec/serializers/merge_request_basic_entity_spec.rb
+++ b/spec/serializers/merge_request_basic_entity_spec.rb
@@ -3,7 +3,8 @@
require 'spec_helper'
RSpec.describe MergeRequestBasicEntity do
- let(:resource) { build(:merge_request) }
+ let(:resource) { build(:merge_request, params) }
+ let(:params) { {} }
subject do
described_class.new(resource).as_json
@@ -14,4 +15,28 @@ RSpec.describe MergeRequestBasicEntity do
expect(subject[:merge_status]).to eq 'checking'
end
+
+ describe '#reviewers' do
+ let(:params) { { reviewers: [reviewer] } }
+ let(:reviewer) { build(:user) }
+
+ context 'when merge_request_reviewers feature is disabled' do
+ it 'does not contain assignees attributes' do
+ stub_feature_flags(merge_request_reviewers: false)
+
+ expect(subject[:reviewers]).to be_nil
+ end
+ end
+
+ context 'when merge_request_reviewers feature is enabled' do
+ it 'contains reviewers attributes' do
+ stub_feature_flags(merge_request_reviewers: true)
+
+ expect(subject[:reviewers].count).to be 1
+ expect(subject[:reviewers].first.keys).to include(
+ :id, :name, :username, :state, :avatar_url, :web_url
+ )
+ 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 e5f88e31025..161940dd01a 100644
--- a/spec/serializers/merge_request_poll_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_poll_widget_entity_spec.rb
@@ -265,7 +265,7 @@ RSpec.describe MergeRequestPollWidgetEntity do
context 'when is not up to date' do
it 'returns nil' do
- pipeline.update(sha: "not up to date")
+ pipeline.update!(sha: "not up to date")
expect(subject[:pipeline]).to eq(nil)
end
@@ -285,4 +285,20 @@ RSpec.describe MergeRequestPollWidgetEntity do
end
end
end
+
+ describe '#builds_with_coverage' do
+ it 'serializes the builds with coverage' do
+ allow(resource).to receive(:head_pipeline_builds_with_coverage).and_return([
+ double(name: 'rspec', coverage: 91.5),
+ double(name: 'jest', coverage: 94.1)
+ ])
+
+ result = subject[:builds_with_coverage]
+
+ expect(result).to eq([
+ { name: 'rspec', coverage: 91.5 },
+ { name: 'jest', coverage: 94.1 }
+ ])
+ end
+ end
end
diff --git a/spec/serializers/merge_request_sidebar_extras_entity_spec.rb b/spec/serializers/merge_request_sidebar_extras_entity_spec.rb
new file mode 100644
index 00000000000..74e9956c8a0
--- /dev/null
+++ b/spec/serializers/merge_request_sidebar_extras_entity_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequestSidebarExtrasEntity do
+ let_it_be(:assignee) { build(:user) }
+ let_it_be(:reviewer) { build(:user) }
+ let_it_be(:user) { build(:user) }
+ let_it_be(:project) { create :project, :repository }
+
+ let(:params) do
+ {
+ source_project: project,
+ target_project: project,
+ assignees: [assignee],
+ reviewers: [reviewer]
+ }
+ end
+
+ let(:resource) do
+ build(:merge_request, params)
+ end
+
+ let(:request) { double('request', current_user: user, project: project) }
+
+ let(:entity) { described_class.new(resource, request: request).as_json }
+
+ describe '#assignees' do
+ it 'contains assignees attributes' do
+ expect(entity[:assignees].count).to be 1
+ expect(entity[:assignees].first.keys).to include(
+ :id, :name, :username, :state, :avatar_url, :web_url, :can_merge
+ )
+ end
+ end
+
+ describe '#reviewers' do
+ context 'when merge_request_reviewers feature is disabled' do
+ it 'does not contain reviewers attributes' do
+ stub_feature_flags(merge_request_reviewers: false)
+
+ expect(entity[:reviewers]).to be_nil
+ end
+ end
+
+ context 'when merge_request_reviewers feature is enabled' do
+ it 'contains reviewers attributes' do
+ stub_feature_flags(merge_request_reviewers: true)
+
+ expect(entity[:reviewers].count).to be 1
+ expect(entity[:reviewers].first.keys).to include(
+ :id, :name, :username, :state, :avatar_url, :web_url, :can_merge
+ )
+ end
+ end
+ end
+end
diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb
index 1704208d8b9..1432c4499ae 100644
--- a/spec/serializers/merge_request_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_widget_entity_spec.rb
@@ -136,9 +136,22 @@ RSpec.describe MergeRequestWidgetEntity do
let(:role) { :developer }
it 'has add ci config path' do
- expected_path = "/#{resource.project.full_path}/-/new/#{resource.source_branch}?commit_message=Add+.gitlab-ci.yml&file_name=.gitlab-ci.yml&suggest_gitlab_ci_yml=true"
+ expected_path = "/#{resource.project.full_path}/-/new/#{resource.source_branch}"
- expect(subject[:merge_request_add_ci_config_path]).to eq(expected_path)
+ expect(subject[:merge_request_add_ci_config_path]).to include(expected_path)
+ end
+
+ it 'has expected params' do
+ expected_params = {
+ commit_message: 'Add .gitlab-ci.yml',
+ file_name: '.gitlab-ci.yml',
+ suggest_gitlab_ci_yml: 'true',
+ mr_path: "/#{resource.project.full_path}/-/merge_requests/#{resource.iid}"
+ }.with_indifferent_access
+
+ uri = Addressable::URI.parse(subject[:merge_request_add_ci_config_path])
+
+ expect(uri.query_values).to match(expected_params)
end
context 'when auto devops is enabled' do
@@ -197,7 +210,7 @@ RSpec.describe MergeRequestWidgetEntity do
context 'when build feature is disabled' do
before do
- project.project_feature.update(builds_access_level: ProjectFeature::DISABLED)
+ project.project_feature.update!(builds_access_level: ProjectFeature::DISABLED)
end
it 'has no path' do
@@ -333,7 +346,7 @@ RSpec.describe MergeRequestWidgetEntity do
it 'returns a blank rebase_path' do
allow(merge_request).to receive(:should_be_rebased?).and_return(true)
- forked_project.destroy
+ forked_project.destroy!
merge_request.reload
entity = described_class.new(merge_request, request: request).as_json
diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb
index 35ce7c7175c..1357836cb89 100644
--- a/spec/serializers/pipeline_details_entity_spec.rb
+++ b/spec/serializers/pipeline_details_entity_spec.rb
@@ -6,6 +6,10 @@ RSpec.describe PipelineDetailsEntity do
let_it_be(:user) { create(:user) }
let(:request) { double('request') }
+ let(:entity) do
+ described_class.represent(pipeline, request: request)
+ end
+
it 'inherrits from PipelineEntity' do
expect(described_class).to be < PipelineEntity
end
@@ -16,10 +20,6 @@ RSpec.describe PipelineDetailsEntity do
allow(request).to receive(:current_user).and_return(user)
end
- let(:entity) do
- described_class.represent(pipeline, request: request)
- end
-
describe '#as_json' do
subject { entity.as_json }
diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb
index e00f05a8fe8..d7cd13edec8 100644
--- a/spec/serializers/pipeline_entity_spec.rb
+++ b/spec/serializers/pipeline_entity_spec.rb
@@ -260,13 +260,5 @@ RSpec.describe PipelineEntity do
end
end
end
-
- context 'when pipeline has build report results' do
- let(:pipeline) { create(:ci_pipeline, :with_report_results, project: project, user: user) }
-
- it 'exposes tests total count' do
- expect(subject[:tests_total_count]).to eq(2)
- end
- end
end
end
diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb
index dfe51e9006f..b42a4f6ad3f 100644
--- a/spec/serializers/pipeline_serializer_spec.rb
+++ b/spec/serializers/pipeline_serializer_spec.rb
@@ -155,7 +155,7 @@ RSpec.describe PipelineSerializer do
it 'verifies number of queries', :request_store do
recorded = ActiveRecord::QueryRecorder.new { subject }
- expected_queries = Gitlab.ee? ? 46 : 43
+ expected_queries = Gitlab.ee? ? 43 : 40
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)
diff --git a/spec/serializers/test_case_entity_spec.rb b/spec/serializers/test_case_entity_spec.rb
index bd2a1b0fb98..32e9562f4c1 100644
--- a/spec/serializers/test_case_entity_spec.rb
+++ b/spec/serializers/test_case_entity_spec.rb
@@ -5,6 +5,8 @@ require 'spec_helper'
RSpec.describe TestCaseEntity do
include TestReportsHelper
+ let_it_be(:job) { create(:ci_build) }
+
let(:entity) { described_class.new(test_case) }
describe '#as_json' do
@@ -38,7 +40,7 @@ RSpec.describe TestCaseEntity do
end
context 'when attachment is present' do
- let(:test_case) { build(:test_case, :failed_with_attachment) }
+ let(:test_case) { build(:test_case, :failed_with_attachment, job: job) }
it 'returns the attachment_url' do
expect(subject).to include(:attachment_url)
@@ -46,7 +48,7 @@ RSpec.describe TestCaseEntity do
end
context 'when attachment is not present' do
- let(:test_case) { build(:test_case) }
+ let(:test_case) { build(:test_case, job: job) }
it 'returns a nil attachment_url' do
expect(subject[:attachment_url]).to be_nil
@@ -60,7 +62,7 @@ RSpec.describe TestCaseEntity do
end
context 'when attachment is present' do
- let(:test_case) { build(:test_case, :failed_with_attachment) }
+ let(:test_case) { build(:test_case, :failed_with_attachment, job: job) }
it 'returns no attachment_url' do
expect(subject).not_to include(:attachment_url)
@@ -68,7 +70,7 @@ RSpec.describe TestCaseEntity do
end
context 'when attachment is not present' do
- let(:test_case) { build(:test_case) }
+ let(:test_case) { build(:test_case, job: job) }
it 'returns no attachment_url' do
expect(subject).not_to include(:attachment_url)
diff --git a/spec/services/admin/propagate_integration_service_spec.rb b/spec/services/admin/propagate_integration_service_spec.rb
index 2e879cf06d1..49d974b7154 100644
--- a/spec/services/admin/propagate_integration_service_spec.rb
+++ b/spec/services/admin/propagate_integration_service_spec.rb
@@ -4,8 +4,15 @@ require 'spec_helper'
RSpec.describe Admin::PropagateIntegrationService do
describe '.propagate' do
- let(:excluded_attributes) { %w[id project_id inherit_from_id instance created_at updated_at default] }
+ include JiraServiceHelper
+
+ before do
+ stub_jira_service_test
+ end
+
+ let(:excluded_attributes) { %w[id project_id group_id inherit_from_id instance created_at updated_at default] }
let!(:project) { create(:project) }
+ let!(:group) { create(:group) }
let!(:instance_integration) do
JiraService.create!(
instance: true,
@@ -43,7 +50,7 @@ RSpec.describe Admin::PropagateIntegrationService do
)
end
- let!(:another_inherited_integration) do
+ let!(:different_type_inherited_integration) do
BambooService.create!(
project: create(:project),
inherit_from_id: instance_integration.id,
@@ -59,7 +66,7 @@ RSpec.describe Admin::PropagateIntegrationService do
shared_examples 'inherits settings from integration' do
it 'updates the inherited integrations' do
- described_class.propagate(integration: instance_integration, overwrite: overwrite)
+ described_class.propagate(instance_integration)
expect(integration.reload.inherit_from_id).to eq(instance_integration.id)
expect(integration.attributes.except(*excluded_attributes))
@@ -70,7 +77,7 @@ RSpec.describe Admin::PropagateIntegrationService do
let(:excluded_attributes) { %w[id service_id created_at updated_at] }
it 'updates the data fields from inherited integrations' do
- described_class.propagate(integration: instance_integration, overwrite: overwrite)
+ described_class.propagate(instance_integration)
expect(integration.reload.data_fields.attributes.except(*excluded_attributes))
.to eq(instance_integration.data_fields.attributes.except(*excluded_attributes))
@@ -80,7 +87,7 @@ RSpec.describe Admin::PropagateIntegrationService do
shared_examples 'does not inherit settings from integration' do
it 'does not update the not inherited integrations' do
- described_class.propagate(integration: instance_integration, overwrite: overwrite)
+ described_class.propagate(instance_integration)
expect(integration.reload.attributes.except(*excluded_attributes))
.not_to eq(instance_integration.attributes.except(*excluded_attributes))
@@ -88,8 +95,6 @@ RSpec.describe Admin::PropagateIntegrationService do
end
context 'update only inherited integrations' do
- let(:overwrite) { false }
-
it_behaves_like 'inherits settings from integration' do
let(:integration) { inherited_integration }
end
@@ -99,36 +104,20 @@ RSpec.describe Admin::PropagateIntegrationService do
end
it_behaves_like 'does not inherit settings from integration' do
- let(:integration) { another_inherited_integration }
+ let(:integration) { different_type_inherited_integration }
end
it_behaves_like 'inherits settings from integration' do
let(:integration) { project.jira_service }
end
- end
-
- context 'update all integrations' do
- let(:overwrite) { true }
-
- it_behaves_like 'inherits settings from integration' do
- let(:integration) { inherited_integration }
- end
it_behaves_like 'inherits settings from integration' do
- let(:integration) { not_inherited_integration }
- end
-
- it_behaves_like 'does not inherit settings from integration' do
- let(:integration) { another_inherited_integration }
- end
-
- it_behaves_like 'inherits settings from integration' do
- let(:integration) { project.jira_service }
+ let(:integration) { Service.find_by(group_id: group.id) }
end
end
it 'updates project#has_external_issue_tracker for issue tracker services' do
- described_class.propagate(integration: instance_integration, overwrite: true)
+ described_class.propagate(instance_integration)
expect(project.reload.has_external_issue_tracker).to eq(true)
end
@@ -141,7 +130,7 @@ RSpec.describe Admin::PropagateIntegrationService do
external_wiki_url: 'http://external-wiki-url.com'
)
- described_class.propagate(integration: instance_integration, overwrite: true)
+ described_class.propagate(instance_integration)
expect(project.reload.has_external_wiki).to eq(true)
end
diff --git a/spec/services/projects/propagate_service_template_spec.rb b/spec/services/admin/propagate_service_template_spec.rb
index df69e5a29fb..15654653095 100644
--- a/spec/services/projects/propagate_service_template_spec.rb
+++ b/spec/services/admin/propagate_service_template_spec.rb
@@ -2,10 +2,10 @@
require 'spec_helper'
-RSpec.describe Projects::PropagateServiceTemplate do
+RSpec.describe Admin::PropagateServiceTemplate do
describe '.propagate' do
let!(:service_template) do
- PushoverService.create(
+ PushoverService.create!(
template: true,
active: true,
push_events: false,
@@ -31,8 +31,7 @@ RSpec.describe Projects::PropagateServiceTemplate do
end
it 'creates services for a project that has another service' do
- BambooService.create(
- template: true,
+ BambooService.create!(
active: true,
project: project,
properties: {
@@ -51,7 +50,7 @@ RSpec.describe Projects::PropagateServiceTemplate do
end
it 'does not create the service if it exists already' do
- other_service = BambooService.create(
+ other_service = BambooService.create!(
template: true,
active: true,
properties: {
@@ -79,7 +78,11 @@ RSpec.describe Projects::PropagateServiceTemplate do
end
context 'service with data fields' do
+ include JiraServiceHelper
+
let(:service_template) do
+ stub_jira_service_test
+
JiraService.create!(
template: true,
active: true,
@@ -106,7 +109,7 @@ RSpec.describe Projects::PropagateServiceTemplate do
let(:project_total) { 5 }
before do
- stub_const 'Projects::PropagateServiceTemplate::BATCH_SIZE', 3
+ stub_const('Admin::PropagateServiceTemplate::BATCH_SIZE', 3)
project_total.times { create(:project) }
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 cf24188a738..f2be317a13d 100644
--- a/spec/services/alert_management/create_alert_issue_service_spec.rb
+++ b/spec/services/alert_management/create_alert_issue_service_spec.rb
@@ -66,7 +66,7 @@ RSpec.describe AlertManagement::CreateAlertIssueService do
end
it 'sets the issue description' do
- expect(created_issue.description).to include(alert_presenter.issue_summary_markdown.strip)
+ expect(created_issue.description).to include(alert_presenter.send(:issue_summary_markdown).strip)
end
end
@@ -82,6 +82,30 @@ RSpec.describe AlertManagement::CreateAlertIssueService do
expect(user).to have_received(:can?).with(:create_issue, project)
end
+ context 'with alert severity' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:alert_severity, :incident_severity) do
+ 'critical' | 'critical'
+ 'high' | 'high'
+ 'medium' | 'medium'
+ 'low' | 'low'
+ 'info' | 'unknown'
+ 'unknown' | 'unknown'
+ end
+
+ with_them do
+ before do
+ alert.update!(severity: alert_severity)
+ execute
+ end
+
+ it 'sets the correct severity level' do
+ expect(created_issue.severity).to eq(incident_severity)
+ end
+ end
+ end
+
context 'when the alert is prometheus alert' do
let(:alert) { prometheus_alert }
let(:issue) { subject.payload[:issue] }
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 533e2473cb8..b14cc65506a 100644
--- a/spec/services/alert_management/process_prometheus_alert_service_spec.rb
+++ b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
@@ -3,17 +3,29 @@
require 'spec_helper'
RSpec.describe AlertManagement::ProcessPrometheusAlertService do
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:project, reload: true) { create(:project, :repository) }
before do
allow(ProjectServiceWorker).to receive(:perform_async)
end
describe '#execute' do
- subject(:execute) { described_class.new(project, nil, payload).execute }
+ let(:service) { described_class.new(project, nil, payload) }
+ let(:incident_management_setting) { double(auto_close_incident?: auto_close_incident, create_issue?: create_issue) }
+ let(:auto_close_incident) { true }
+ let(:create_issue) { true }
+
+ before do
+ allow(service)
+ .to receive(:incident_management_setting)
+ .and_return(incident_management_setting)
+ end
+
+ subject(:execute) { service.execute }
context 'when alert payload is valid' do
- let(:parsed_alert) { Gitlab::Alerting::Alert.new(project: project, payload: payload) }
+ let(:parsed_payload) { Gitlab::AlertManagement::Payload.parse(project, payload, monitoring_tool: 'Prometheus') }
+ let(:fingerprint) { parsed_payload.gitlab_fingerprint }
let(:payload) do
{
'status' => status,
@@ -39,25 +51,26 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
context 'when Prometheus alert status is firing' do
context 'when alert with the same fingerprint already exists' do
- let!(:alert) { create(:alert_management_alert, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+ let!(:alert) { create(:alert_management_alert, project: project, fingerprint: fingerprint) }
it_behaves_like 'adds an alert management alert event'
+ it_behaves_like 'processes incident issues'
context 'existing alert is resolved' do
- let!(:alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+ let!(:alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: fingerprint) }
it_behaves_like 'creates an alert management alert'
end
context 'existing alert is ignored' do
- let!(:alert) { create(:alert_management_alert, :ignored, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+ let!(:alert) { create(:alert_management_alert, :ignored, project: project, fingerprint: fingerprint) }
it_behaves_like 'adds an alert management alert event'
end
context 'two existing alerts, one resolved one open' do
- let!(:resolved_alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
- let!(:alert) { create(:alert_management_alert, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+ let!(:resolved_alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: fingerprint) }
+ let!(:alert) { create(:alert_management_alert, project: project, fingerprint: fingerprint) }
it_behaves_like 'adds an alert management alert event'
end
@@ -78,46 +91,47 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
execute
end
end
+
+ context 'when auto-alert creation is disabled' do
+ let(:create_issue) { false }
+
+ it_behaves_like 'does not process incident issues'
+ end
end
context 'when alert does not exist' do
context 'when alert can be created' do
it_behaves_like 'creates an alert management alert'
- it 'processes the incident alert' do
- expect(IncidentManagement::ProcessAlertWorker)
- .to receive(:perform_async)
- .with(nil, nil, kind_of(Integer))
- .once
+ it 'creates a system note corresponding to alert creation' do
+ expect { subject }.to change(Note, :count).by(1)
+ end
+
+ it_behaves_like 'processes incident issues'
- expect(subject).to be_success
+ context 'when auto-alert creation is disabled' do
+ let(:create_issue) { false }
+
+ it_behaves_like 'does not process incident issues'
end
end
context 'when alert cannot be created' do
- let(:errors) { double(messages: { hosts: ['hosts array is over 255 chars'] })}
- let(:am_alert) { instance_double(AlertManagement::Alert, save: false, errors: errors) }
-
before do
- allow(AlertManagement::Alert).to receive(:new).and_return(am_alert)
+ payload['annotations']['title'] = 'description' * 50
end
it 'writes a warning to the log' do
expect(Gitlab::AppLogger).to receive(:warn).with(
message: 'Unable to create AlertManagement::Alert',
project_id: project.id,
- alert_errors: { hosts: ['hosts array is over 255 chars'] }
+ alert_errors: { title: ["is too long (maximum is 200 characters)"] }
)
execute
end
- it 'does not create incident issue' do
- expect(IncidentManagement::ProcessAlertWorker)
- .not_to receive(:perform_async)
-
- expect(subject).to be_success
- end
+ it_behaves_like 'does not process incident issues'
end
it { is_expected.to be_success }
@@ -126,57 +140,67 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
context 'when Prometheus alert status is resolved' do
let(:status) { 'resolved' }
- let!(:alert) { create(:alert_management_alert, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+ let!(:alert) { create(:alert_management_alert, project: project, fingerprint: fingerprint) }
- context 'when status can be changed' do
- it 'resolves an existing alert' do
- expect { execute }.to change { alert.reload.resolved? }.to(true)
- end
+ context 'when auto_resolve_incident set to true' do
+ context 'when status can be changed' do
+ it 'resolves an existing alert' do
+ expect { execute }.to change { alert.reload.resolved? }.to(true)
+ end
- [true, false].each do |state_tracking_enabled|
- context 'existing issue' do
- before do
- stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
- end
+ [true, false].each do |state_tracking_enabled|
+ context 'existing issue' do
+ before do
+ stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
+ end
- let!(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+ let!(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: fingerprint) }
- it 'closes the issue' do
- issue = alert.issue
+ it 'closes the issue' do
+ issue = alert.issue
- expect { execute }
- .to change { issue.reload.state }
- .from('opened')
- .to('closed')
- end
+ expect { execute }
+ .to change { issue.reload.state }
+ .from('opened')
+ .to('closed')
+ end
- if state_tracking_enabled
- specify { expect { execute }.to change(ResourceStateEvent, :count).by(1) }
- else
- specify { expect { execute }.to change(Note, :count).by(1) }
+ if state_tracking_enabled
+ specify { expect { execute }.to change(ResourceStateEvent, :count).by(1) }
+ else
+ specify { expect { execute }.to change(Note, :count).by(1) }
+ end
end
end
end
- end
- context 'when status change did not succeed' do
- before do
- allow(AlertManagement::Alert).to receive(:for_fingerprint).and_return([alert])
- allow(alert).to receive(:resolve).and_return(false)
- end
+ context 'when status change did not succeed' do
+ before do
+ allow(AlertManagement::Alert).to receive(:for_fingerprint).and_return([alert])
+ allow(alert).to receive(:resolve).and_return(false)
+ end
- it 'writes a warning to the log' do
- expect(Gitlab::AppLogger).to receive(:warn).with(
- message: 'Unable to update AlertManagement::Alert status to resolved',
- project_id: project.id,
- alert_id: alert.id
- )
+ it 'writes a warning to the log' do
+ expect(Gitlab::AppLogger).to receive(:warn).with(
+ message: 'Unable to update AlertManagement::Alert status to resolved',
+ project_id: project.id,
+ alert_id: alert.id
+ )
- execute
+ execute
+ end
end
+
+ it { is_expected.to be_success }
end
- it { is_expected.to be_success }
+ context 'when auto_resolve_incident set to false' do
+ let(:auto_close_incident) { false }
+
+ it 'does not resolve an existing alert' do
+ expect { execute }.not_to change { alert.reload.resolved? }
+ end
+ end
end
context 'environment given' do
diff --git a/spec/services/audit_event_service_spec.rb b/spec/services/audit_event_service_spec.rb
index 530d3469481..93de2a23edc 100644
--- a/spec/services/audit_event_service_spec.rb
+++ b/spec/services/audit_event_service_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe AuditEventService do
entity_type: "Project",
action: :destroy)
- expect { service.security_event }.to change(SecurityEvent, :count).by(1)
+ expect { service.security_event }.to change(AuditEvent, :count).by(1)
end
it 'formats from and to fields' do
@@ -44,14 +44,30 @@ RSpec.describe AuditEventService do
action: :create,
target_id: 1)
- expect { service.security_event }.to change(SecurityEvent, :count).by(1)
+ expect { service.security_event }.to change(AuditEvent, :count).by(1)
- details = SecurityEvent.last.details
+ details = AuditEvent.last.details
expect(details[:from]).to be true
expect(details[:to]).to be false
expect(details[:action]).to eq(:create)
expect(details[:target_id]).to eq(1)
end
+
+ context 'authentication event' do
+ let(:audit_service) { described_class.new(user, user, with: 'standard') }
+
+ it 'creates an authentication event' do
+ expect(AuthenticationEvent).to receive(:create).with(
+ user: user,
+ user_name: user.name,
+ ip_address: user.current_sign_in_ip,
+ result: AuthenticationEvent.results[:success],
+ provider: 'standard'
+ )
+
+ audit_service.for_authentication.security_event
+ end
+ end
end
describe '#log_security_event_to_file' do
diff --git a/spec/services/authorized_project_update/project_create_service_spec.rb b/spec/services/authorized_project_update/project_create_service_spec.rb
index 891800bfb87..a9d0b82acfb 100644
--- a/spec/services/authorized_project_update/project_create_service_spec.rb
+++ b/spec/services/authorized_project_update/project_create_service_spec.rb
@@ -81,6 +81,7 @@ RSpec.describe AuthorizedProjectUpdate::ProjectCreateService do
before do
create(:group_member, access_level: Gitlab::Access::REPORTER, group: group, user: group_user)
create(:group_member, access_level: Gitlab::Access::MAINTAINER, group: shared_with_group, user: group_user)
+ create(:group_member, :minimal_access, source: shared_with_group, user: create(:user))
create(:group_group_link, shared_group: group, shared_with_group: shared_with_group, group_access: Gitlab::Access::DEVELOPER)
@@ -97,6 +98,11 @@ RSpec.describe AuthorizedProjectUpdate::ProjectCreateService do
access_level: Gitlab::Access::DEVELOPER)
expect(project_authorization).to exist
end
+
+ it 'does not create project authorization for user with minimal access' do
+ expect { service.execute }.to(
+ change { ProjectAuthorization.count }.from(0).to(1))
+ end
end
end
@@ -118,6 +124,17 @@ RSpec.describe AuthorizedProjectUpdate::ProjectCreateService do
end
end
+ context 'member with minimal access' do
+ before do
+ create(:group_member, :minimal_access, user: group_user, source: group)
+ end
+
+ it 'does not create project authorization' do
+ expect { service.execute }.not_to(
+ change { ProjectAuthorization.count }.from(0))
+ end
+ end
+
context 'project has more user than BATCH_SIZE' do
let(:batch_size) { 2 }
let(:users) { create_list(:user, batch_size + 1 ) }
diff --git a/spec/services/authorized_project_update/project_group_link_create_service_spec.rb b/spec/services/authorized_project_update/project_group_link_create_service_spec.rb
index 961322a1a21..1fd47f78c24 100644
--- a/spec/services/authorized_project_update/project_group_link_create_service_spec.rb
+++ b/spec/services/authorized_project_update/project_group_link_create_service_spec.rb
@@ -112,6 +112,17 @@ RSpec.describe AuthorizedProjectUpdate::ProjectGroupLinkCreateService do
end
end
+ context 'minimal access member' do
+ before do
+ create(:group_member, :minimal_access, user: group_user, source: group)
+ end
+
+ it 'does not create project authorization' do
+ expect { service.execute }.not_to(
+ change { ProjectAuthorization.count }.from(0))
+ end
+ end
+
context 'project has more users than BATCH_SIZE' do
let(:batch_size) { 2 }
let(:users) { create_list(:user, batch_size + 1 ) }
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 3bf59f6a2d1..7b428550768 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
@@ -91,18 +91,6 @@ RSpec.describe AutoMerge::MergeWhenPipelineSucceedsService do
end
end
- context 'without feature enabled' do
- it 'does not send notification' do
- stub_feature_flags(mwps_notification: false)
-
- allow(merge_request)
- .to receive_messages(head_pipeline: pipeline, actual_head_pipeline: pipeline)
- expect(MailScheduler::NotificationServiceWorker).not_to receive(:perform_async)
-
- service.execute(merge_request)
- end
- end
-
context 'already approved' do
let(:service) { described_class.new(project, user, should_remove_source_branch: true) }
let(:build) { create(:ci_build, ref: mr_merge_if_green_enabled.source_branch) }
diff --git a/spec/services/branches/delete_service_spec.rb b/spec/services/branches/delete_service_spec.rb
index f1e7c9340b1..291431c1723 100644
--- a/spec/services/branches/delete_service_spec.rb
+++ b/spec/services/branches/delete_service_spec.rb
@@ -37,6 +37,21 @@ RSpec.describe Branches::DeleteService do
end
it_behaves_like 'a deleted branch', 'feature'
+
+ context 'when Gitlab::Git::CommandError is raised' do
+ before do
+ allow(repository).to receive(:rm_branch) do
+ raise Gitlab::Git::CommandError.new('Could not update patch')
+ end
+ end
+
+ it 'handles and returns error' do
+ result = service.execute('feature')
+
+ expect(result.status).to eq(:error)
+ expect(result.message).to eq('Could not update patch')
+ end
+ end
end
context 'when user does not have access to push to repository' do
diff --git a/spec/services/ci/cancel_user_pipelines_service_spec.rb b/spec/services/ci/cancel_user_pipelines_service_spec.rb
index 12117051b64..8491242dfd5 100644
--- a/spec/services/ci/cancel_user_pipelines_service_spec.rb
+++ b/spec/services/ci/cancel_user_pipelines_service_spec.rb
@@ -19,5 +19,17 @@ RSpec.describe Ci::CancelUserPipelinesService do
expect(build.reload).to be_canceled
end
end
+
+ context 'when an error ocurrs' do
+ it 'raises a service level error' do
+ service = double(execute: ServiceResponse.error(message: 'Error canceling pipeline'))
+ allow(::Ci::CancelUserPipelinesService).to receive(:new).and_return(service)
+
+ result = subject
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result).to be_error
+ end
+ end
end
end
diff --git a/spec/services/ci/create_cross_project_pipeline_service_spec.rb b/spec/services/ci/create_downstream_pipeline_service_spec.rb
index 1aabdb85afd..a6ea30e4703 100644
--- a/spec/services/ci/create_cross_project_pipeline_service_spec.rb
+++ b/spec/services/ci/create_downstream_pipeline_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CreateCrossProjectPipelineService, '#execute' do
+RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
let_it_be(:user) { create(:user) }
let(:upstream_project) { create(:project, :repository) }
let_it_be(:downstream_project) { create(:project, :repository) }
@@ -130,7 +130,7 @@ RSpec.describe Ci::CreateCrossProjectPipelineService, '#execute' do
expect(Gitlab::ErrorTracking)
.to receive(:track_exception)
.with(
- instance_of(Ci::CreateCrossProjectPipelineService::DuplicateDownstreamPipelineError),
+ instance_of(described_class::DuplicateDownstreamPipelineError),
bridge_id: bridge.id, project_id: bridge.project.id)
.and_call_original
expect(Ci::CreatePipelineService).not_to receive(:new)
@@ -179,7 +179,7 @@ RSpec.describe Ci::CreateCrossProjectPipelineService, '#execute' do
end
end
- context 'when downstream project is the same as the job project' do
+ context 'when downstream project is the same as the upstream project' do
let(:trigger) do
{ trigger: { project: upstream_project.full_path } }
end
@@ -311,24 +311,78 @@ RSpec.describe Ci::CreateCrossProjectPipelineService, '#execute' do
end
end
- context 'when upstream pipeline is a child pipeline' do
- let!(:pipeline_source) do
+ context 'when upstream pipeline has a parent pipeline' do
+ before do
create(:ci_sources_pipeline,
source_pipeline: create(:ci_pipeline, project: upstream_pipeline.project),
pipeline: upstream_pipeline
)
end
+ it 'creates the pipeline' do
+ expect { service.execute(bridge) }
+ .to change { Ci::Pipeline.count }.by(1)
+
+ expect(bridge.reload).to be_success
+ end
+
+ context 'when FF ci_child_of_child_pipeline is disabled' do
+ before do
+ stub_feature_flags(ci_child_of_child_pipeline: false)
+ end
+
+ it 'does not create a further child pipeline' do
+ expect { service.execute(bridge) }
+ .not_to change { Ci::Pipeline.count }
+
+ expect(bridge.reload).to be_failed
+ expect(bridge.failure_reason).to eq 'bridge_pipeline_is_child_pipeline'
+ end
+ end
+ end
+
+ context 'when upstream pipeline has a parent pipeline, which has a parent pipeline' do
before do
- upstream_pipeline.update!(source: :parent_pipeline)
+ parent_of_upstream_pipeline = create(:ci_pipeline, project: upstream_pipeline.project)
+
+ create(:ci_sources_pipeline,
+ source_pipeline: create(:ci_pipeline, project: upstream_pipeline.project),
+ pipeline: parent_of_upstream_pipeline
+ )
+
+ create(:ci_sources_pipeline,
+ source_pipeline: parent_of_upstream_pipeline,
+ pipeline: upstream_pipeline
+ )
end
- it 'does not create a further child pipeline' do
+ it 'does not create a second descendant pipeline' do
expect { service.execute(bridge) }
.not_to change { Ci::Pipeline.count }
expect(bridge.reload).to be_failed
- expect(bridge.failure_reason).to eq 'bridge_pipeline_is_child_pipeline'
+ expect(bridge.failure_reason).to eq 'reached_max_descendant_pipelines_depth'
+ end
+ end
+
+ context 'when upstream pipeline has two level upstream pipelines from different projects' do
+ before do
+ upstream_of_upstream_of_upstream_pipeline = create(:ci_pipeline)
+ upstream_of_upstream_pipeline = create(:ci_pipeline)
+
+ create(:ci_sources_pipeline,
+ source_pipeline: upstream_of_upstream_of_upstream_pipeline,
+ pipeline: upstream_of_upstream_pipeline
+ )
+
+ create(:ci_sources_pipeline,
+ source_pipeline: upstream_of_upstream_pipeline,
+ pipeline: upstream_pipeline
+ )
+ end
+
+ it 'create the pipeline' do
+ expect { service.execute(bridge) }.to change { Ci::Pipeline.count }.by(1)
end
end
end
@@ -397,7 +451,7 @@ RSpec.describe Ci::CreateCrossProjectPipelineService, '#execute' do
context 'when pipeline variables are defined' do
before do
- upstream_pipeline.variables.create(key: 'PIPELINE_VARIABLE', value: 'my-value')
+ upstream_pipeline.variables.create!(key: 'PIPELINE_VARIABLE', value: 'my-value')
end
it 'does not pass pipeline variables directly downstream' do
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 3be5ac1f739..b5b3832ac00 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
@@ -101,7 +101,7 @@ RSpec.describe Ci::CreatePipelineService do
end
it 'contains only errors' do
- error_message = 'root config contains unknown keys: invalid'
+ error_message = 'jobs invalid config should implement a script: or a trigger: keyword'
expect(pipeline.yaml_errors).to eq(error_message)
expect(pipeline.error_messages.map(&:content)).to contain_exactly(error_message)
expect(pipeline.errors.full_messages).to contain_exactly(error_message)
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index db4c2f5a047..e0893ed6de3 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -223,7 +223,7 @@ RSpec.describe Ci::CreatePipelineService do
context 'auto-cancel enabled' do
before do
- project.update(auto_cancel_pending_pipelines: 'enabled')
+ project.update!(auto_cancel_pending_pipelines: 'enabled')
end
it 'does not cancel HEAD pipeline' do
@@ -248,7 +248,7 @@ RSpec.describe Ci::CreatePipelineService do
end
it 'cancel created outdated pipelines', :sidekiq_might_not_need_inline do
- pipeline_on_previous_commit.update(status: 'created')
+ pipeline_on_previous_commit.update!(status: 'created')
pipeline
expect(pipeline_on_previous_commit.reload).to have_attributes(status: 'canceled', auto_canceled_by_id: pipeline.id)
@@ -439,7 +439,7 @@ RSpec.describe Ci::CreatePipelineService do
context 'auto-cancel disabled' do
before do
- project.update(auto_cancel_pending_pipelines: 'disabled')
+ project.update!(auto_cancel_pending_pipelines: 'disabled')
end
it 'does not auto cancel pending non-HEAD pipelines' do
@@ -513,7 +513,7 @@ RSpec.describe Ci::CreatePipelineService do
it 'pull it from Auto-DevOps' do
pipeline = execute_service
expect(pipeline).to be_auto_devops_source
- expect(pipeline.builds.map(&:name)).to match_array(%w[build code_quality eslint-sast secret_detection_default_branch secrets-sast test])
+ expect(pipeline.builds.map(&:name)).to match_array(%w[build code_quality eslint-sast secret_detection_default_branch test])
end
end
diff --git a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
index 79443f16276..1c96be42a2f 100644
--- a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
+++ b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
@@ -11,6 +11,10 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
let(:service) { described_class.new }
let!(:artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
+ before do
+ artifact.job.pipeline.unlocked!
+ end
+
context 'when artifact is expired' do
context 'when artifact is not locked' do
before do
@@ -88,6 +92,8 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
before do
stub_const('Ci::DestroyExpiredJobArtifactsService::LOOP_LIMIT', 1)
stub_const('Ci::DestroyExpiredJobArtifactsService::BATCH_SIZE', 1)
+
+ second_artifact.job.pipeline.unlocked!
end
let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
@@ -102,7 +108,9 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
end
context 'when there are no artifacts' do
- let!(:artifact) { }
+ before do
+ artifact.destroy!
+ end
it 'does not raise error' do
expect { subject }.not_to raise_error
@@ -112,6 +120,8 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
context 'when there are artifacts more than batch sizes' do
before do
stub_const('Ci::DestroyExpiredJobArtifactsService::BATCH_SIZE', 1)
+
+ second_artifact.job.pipeline.unlocked!
end
let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
@@ -120,5 +130,45 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
expect { subject }.to change { Ci::JobArtifact.count }.by(-2)
end
end
+
+ context 'when artifact is a pipeline artifact' do
+ context 'when artifacts are expired' do
+ let!(:pipeline_artifact_1) { create(:ci_pipeline_artifact, expire_at: 1.week.ago) }
+ let!(:pipeline_artifact_2) { create(:ci_pipeline_artifact, expire_at: 1.week.ago) }
+
+ before do
+ [pipeline_artifact_1, pipeline_artifact_2].each { |pipeline_artifact| pipeline_artifact.pipeline.unlocked! }
+ end
+
+ it 'destroys pipeline artifacts' do
+ expect { subject }.to change { Ci::PipelineArtifact.count }.by(-2)
+ end
+ end
+
+ context 'when artifacts are not expired' do
+ let!(:pipeline_artifact_1) { create(:ci_pipeline_artifact, expire_at: 2.days.from_now) }
+ let!(:pipeline_artifact_2) { create(:ci_pipeline_artifact, expire_at: 2.days.from_now) }
+
+ before do
+ [pipeline_artifact_1, pipeline_artifact_2].each { |pipeline_artifact| pipeline_artifact.pipeline.unlocked! }
+ end
+
+ it 'does not destroy pipeline artifacts' do
+ expect { subject }.not_to change { Ci::PipelineArtifact.count }
+ end
+ end
+ end
+
+ context 'when some artifacts are locked' do
+ before do
+ pipeline = create(:ci_pipeline, locked: :artifacts_locked)
+ job = create(:ci_build, pipeline: pipeline)
+ create(:ci_job_artifact, expire_at: 1.day.ago, job: job)
+ end
+
+ it 'destroys only unlocked artifacts' do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
+ end
+ end
end
end
diff --git a/spec/services/ci/destroy_pipeline_service_spec.rb b/spec/services/ci/destroy_pipeline_service_spec.rb
index 23cbe683d2f..6977c99e335 100644
--- a/spec/services/ci/destroy_pipeline_service_spec.rb
+++ b/spec/services/ci/destroy_pipeline_service_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe ::Ci::DestroyPipelineService do
end
it 'does not log an audit event' do
- expect { subject }.not_to change { SecurityEvent.count }
+ expect { subject }.not_to change { AuditEvent.count }
end
context 'when the pipeline has jobs' do
diff --git a/spec/services/ci/generate_coverage_reports_service_spec.rb b/spec/services/ci/generate_coverage_reports_service_spec.rb
index a3ed2eec713..d39053adebc 100644
--- a/spec/services/ci/generate_coverage_reports_service_spec.rb
+++ b/spec/services/ci/generate_coverage_reports_service_spec.rb
@@ -15,21 +15,25 @@ RSpec.describe Ci::GenerateCoverageReportsService do
let!(:head_pipeline) { merge_request.head_pipeline }
let!(:base_pipeline) { nil }
- it 'returns status and data' do
+ it 'returns status and data', :aggregate_failures do
+ expect_any_instance_of(Ci::PipelineArtifact) do |instance|
+ expect(instance).to receive(:present)
+ expect(instance).to receive(:for_files).with(merge_request.new_paths).and_call_original
+ end
+
expect(subject[:status]).to eq(:parsed)
expect(subject[:data]).to eq(files: {})
end
end
- context 'when head pipeline has corrupted coverage reports' do
+ context 'when head pipeline does not have a coverage report artifact' do
let!(:merge_request) { create(:merge_request, :with_coverage_reports, source_project: project) }
let!(:service) { described_class.new(project, nil, id: merge_request.id) }
let!(:head_pipeline) { merge_request.head_pipeline }
let!(:base_pipeline) { nil }
before do
- build = create(:ci_build, pipeline: head_pipeline, project: head_pipeline.project)
- create(:ci_job_artifact, :coverage_with_corrupted_data, job: build, project: project)
+ head_pipeline.pipeline_artifacts.destroy_all # rubocop: disable Cop/DestroyAll
end
it 'returns status and error message' do
diff --git a/spec/services/ci/parse_dotenv_artifact_service_spec.rb b/spec/services/ci/parse_dotenv_artifact_service_spec.rb
index a5f01187a83..91b81af9fd1 100644
--- a/spec/services/ci/parse_dotenv_artifact_service_spec.rb
+++ b/spec/services/ci/parse_dotenv_artifact_service_spec.rb
@@ -66,12 +66,13 @@ RSpec.describe Ci::ParseDotenvArtifactService do
end
context 'when multiple key/value pairs exist in one line' do
- let(:blob) { 'KEY1=VAR1KEY2=VAR1' }
+ let(:blob) { 'KEY=VARCONTAINING=EQLS' }
- it 'returns error' do
- expect(subject[:status]).to eq(:error)
- expect(subject[:message]).to eq("Validation failed: Key can contain only letters, digits and '_'.")
- expect(subject[:http_status]).to eq(:bad_request)
+ it 'parses the dotenv data' do
+ subject
+
+ expect(build.job_variables.as_json).to contain_exactly(
+ hash_including('key' => 'KEY', 'value' => 'VARCONTAINING=EQLS'))
end
end
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds_deploy_needs_one_build_and_test.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds_deploy_needs_one_build_and_test.yml
index a133023b12d..ef4ddff9b64 100644
--- a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds_deploy_needs_one_build_and_test.yml
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds_deploy_needs_one_build_and_test.yml
@@ -47,16 +47,13 @@ transitions:
- event: drop
jobs: [build_2]
expect:
- pipeline: running
+ pipeline: failed
stages:
build: failed
test: skipped
- deploy: pending
+ deploy: skipped
jobs:
build_1: success
build_2: failed
test: skipped
- deploy: pending
-
-# TODO: should we run deploy?
-# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure_deploy_needs_one_build_and_test.yml b/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure_deploy_needs_one_build_and_test.yml
index f324525bd56..29c1562389c 100644
--- a/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure_deploy_needs_one_build_and_test.yml
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure_deploy_needs_one_build_and_test.yml
@@ -34,30 +34,13 @@ transitions:
- event: success
jobs: [build_1, build_2]
expect:
- pipeline: running
- stages:
- build: success
- test: skipped
- deploy: pending
- jobs:
- build_1: success
- build_2: success
- test: skipped
- deploy: pending
-
- - event: success
- jobs: [deploy]
- expect:
pipeline: success
stages:
build: success
test: skipped
- deploy: success
+ deploy: skipped
jobs:
build_1: success
build_2: success
test: skipped
- deploy: success
-
-# TODO: should we run deploy?
-# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
+ deploy: skipped
diff --git a/spec/services/ci/pipelines/create_artifact_service_spec.rb b/spec/services/ci/pipelines/create_artifact_service_spec.rb
new file mode 100644
index 00000000000..d5e9cf83a6d
--- /dev/null
+++ b/spec/services/ci/pipelines/create_artifact_service_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ci::Pipelines::CreateArtifactService do
+ describe '#execute' do
+ subject { described_class.new.execute(pipeline) }
+
+ context 'when pipeline has coverage reports' do
+ let(:pipeline) { create(:ci_pipeline, :with_coverage_reports) }
+
+ context 'when pipeline is finished' do
+ it 'creates a pipeline artifact' do
+ subject
+
+ expect(Ci::PipelineArtifact.count).to eq(1)
+ end
+
+ it 'persists the default file name' do
+ subject
+
+ file = Ci::PipelineArtifact.first.file
+
+ expect(file.filename).to eq('code_coverage.json')
+ end
+
+ it 'sets expire_at to 1 week' do
+ freeze_time do
+ subject
+
+ pipeline_artifact = Ci::PipelineArtifact.first
+
+ expect(pipeline_artifact.expire_at).to eq(1.week.from_now)
+ end
+ end
+ end
+
+ context 'when feature is disabled' do
+ it 'does not create a pipeline artifact' do
+ stub_feature_flags(coverage_report_view: false)
+
+ subject
+
+ expect(Ci::PipelineArtifact.count).to eq(0)
+ end
+ end
+
+ context 'when pipeline artifact has already been created' do
+ it 'do not raise an error and do not persist the same artifact twice' do
+ expect { 2.times { described_class.new.execute(pipeline) } }.not_to raise_error(ActiveRecord::RecordNotUnique)
+
+ expect(Ci::PipelineArtifact.count).to eq(1)
+ end
+ end
+ end
+
+ context 'when pipeline is running and coverage report does not exist' do
+ let(:pipeline) { create(:ci_pipeline, :running) }
+
+ it 'does not persist data' do
+ subject
+
+ expect(Ci::PipelineArtifact.count).to eq(0)
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb
index 921f5ba4c7e..0cdc8d2c870 100644
--- a/spec/services/ci/register_job_service_spec.rb
+++ b/spec/services/ci/register_job_service_spec.rb
@@ -15,14 +15,14 @@ module Ci
describe '#execute' do
context 'runner follow tag list' do
it "picks build with the same tag" do
- pending_job.update(tag_list: ["linux"])
- specific_runner.update(tag_list: ["linux"])
+ pending_job.update!(tag_list: ["linux"])
+ specific_runner.update!(tag_list: ["linux"])
expect(execute(specific_runner)).to eq(pending_job)
end
it "does not pick build with different tag" do
- pending_job.update(tag_list: ["linux"])
- specific_runner.update(tag_list: ["win32"])
+ pending_job.update!(tag_list: ["linux"])
+ specific_runner.update!(tag_list: ["win32"])
expect(execute(specific_runner)).to be_falsey
end
@@ -31,24 +31,24 @@ module Ci
end
it "does not pick build with tag" do
- pending_job.update(tag_list: ["linux"])
+ pending_job.update!(tag_list: ["linux"])
expect(execute(specific_runner)).to be_falsey
end
it "pick build without tag" do
- specific_runner.update(tag_list: ["win32"])
+ specific_runner.update!(tag_list: ["win32"])
expect(execute(specific_runner)).to eq(pending_job)
end
end
context 'deleted projects' do
before do
- project.update(pending_delete: true)
+ project.update!(pending_delete: true)
end
context 'for shared runners' do
before do
- project.update(shared_runners_enabled: true)
+ project.update!(shared_runners_enabled: true)
end
it 'does not pick a build' do
@@ -65,7 +65,7 @@ module Ci
context 'allow shared runners' do
before do
- project.update(shared_runners_enabled: true)
+ project.update!(shared_runners_enabled: true)
end
context 'for multiple builds' do
@@ -131,7 +131,7 @@ module Ci
context 'disallow shared runners' do
before do
- project.update(shared_runners_enabled: false)
+ project.update!(shared_runners_enabled: false)
end
context 'shared runner' do
@@ -152,7 +152,7 @@ module Ci
context 'disallow when builds are disabled' do
before do
- project.update(shared_runners_enabled: true, group_runners_enabled: true)
+ project.update!(shared_runners_enabled: true, group_runners_enabled: true)
project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
end
@@ -591,8 +591,8 @@ module Ci
.with(:job_queue_duration_seconds, anything, anything, anything)
.and_return(job_queue_duration_seconds)
- project.update(shared_runners_enabled: true)
- pending_job.update(created_at: current_time - 3600, queued_at: current_time - 1800)
+ project.update!(shared_runners_enabled: true)
+ pending_job.update!(created_at: current_time - 3600, queued_at: current_time - 1800)
end
shared_examples 'attempt counter collector' do
@@ -661,7 +661,7 @@ module Ci
context 'when pending job with queued_at=nil is used' do
before do
- pending_job.update(queued_at: nil)
+ pending_job.update!(queued_at: nil)
end
it_behaves_like 'attempt counter collector'
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index 5a245415b32..51741440075 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Ci::RetryBuildService do
described_class.new(project, user)
end
- clone_accessors = described_class::CLONE_ACCESSORS
+ clone_accessors = described_class.clone_accessors
reject_accessors =
%i[id status user token token_encrypted coverage trace runner
@@ -50,7 +50,7 @@ RSpec.describe Ci::RetryBuildService do
metadata 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].freeze
+ pipeline_id report_results pending_state pages_deployments].freeze
shared_examples 'build duplication' do
let(:another_pipeline) { create(:ci_empty_pipeline, project: project) }
@@ -70,7 +70,7 @@ RSpec.describe Ci::RetryBuildService do
# Make sure that build has both `stage_id` and `stage` because FactoryBot
# can reset one of the fields when assigning another. We plan to deprecate
# and remove legacy `stage` column in the future.
- build.update(stage: 'test', stage_id: stage.id)
+ build.update!(stage: 'test', stage_id: stage.id)
# Make sure we have one instance for every possible job_artifact_X
# associations to check they are correctly rejected on build duplication.
@@ -143,6 +143,8 @@ RSpec.describe Ci::RetryBuildService do
Ci::Build.reflect_on_all_associations.map(&:name) +
[:tag_list, :needs_attributes]
+ current_accessors << :secrets if Gitlab.ee?
+
current_accessors.uniq!
expect(current_accessors).to include(*processed_accessors)
@@ -181,17 +183,24 @@ RSpec.describe Ci::RetryBuildService do
service.execute(build)
end
- context 'when there are subsequent builds that are skipped' do
+ context 'when there are subsequent processables that are skipped' do
let!(:subsequent_build) do
create(:ci_build, :skipped, stage_idx: 2,
pipeline: pipeline,
stage: 'deploy')
end
- it 'resumes pipeline processing in a subsequent stage' do
+ let!(:subsequent_bridge) do
+ create(:ci_bridge, :skipped, stage_idx: 2,
+ pipeline: pipeline,
+ stage: 'deploy')
+ end
+
+ it 'resumes pipeline processing in the subsequent stage' do
service.execute(build)
expect(subsequent_build.reload).to be_created
+ expect(subsequent_bridge.reload).to be_created
end
end
@@ -223,6 +232,19 @@ RSpec.describe Ci::RetryBuildService do
end
end
end
+
+ context 'when the pipeline is a child pipeline and the bridge is depended' do
+ let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:pipeline) { create(:ci_pipeline, project: project) }
+ let!(:bridge) { create(:ci_bridge, :strategy_depend, pipeline: parent_pipeline, status: 'success') }
+ let!(:source_pipeline) { create(:ci_sources_pipeline, pipeline: pipeline, source_job: bridge) }
+
+ it 'marks source bridge as pending' do
+ service.execute(build)
+
+ expect(bridge.reload).to be_pending
+ end
+ end
end
context 'when user does not have ability to execute build' do
diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb
index 212c8f99865..526c2f39b46 100644
--- a/spec/services/ci/retry_pipeline_service_spec.rb
+++ b/spec/services/ci/retry_pipeline_service_spec.rb
@@ -280,6 +280,20 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
expect(build3.reload.scheduling_type).to eq('dag')
end
end
+
+ context 'when the pipeline is a downstream pipeline and the bridge is depended' do
+ let!(:bridge) { create(:ci_bridge, :strategy_depend, status: 'success') }
+
+ before do
+ create(:ci_sources_pipeline, pipeline: pipeline, source_job: bridge)
+ end
+
+ it 'marks source bridge as pending' do
+ service.execute(pipeline)
+
+ expect(bridge.reload).to be_pending
+ end
+ end
end
context 'when user is not allowed to retry pipeline' do
diff --git a/spec/services/ci/update_build_state_service_spec.rb b/spec/services/ci/update_build_state_service_spec.rb
new file mode 100644
index 00000000000..f5ad732bf7e
--- /dev/null
+++ b/spec/services/ci/update_build_state_service_spec.rb
@@ -0,0 +1,238 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::UpdateBuildStateService do
+ let(:project) { create(:project) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, :running, pipeline: pipeline) }
+ let(:metrics) { spy('metrics') }
+
+ subject { described_class.new(build, params) }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ end
+
+ context 'when build does not have checksum' do
+ context 'when state has changed' do
+ let(:params) { { state: 'success' } }
+
+ it 'updates a state of a running build' do
+ subject.execute
+
+ expect(build).to be_success
+ end
+
+ it 'returns 200 OK status' do
+ result = subject.execute
+
+ expect(result.status).to eq 200
+ end
+
+ it 'does not increment finalized trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .not_to have_received(:increment_trace_operation)
+ .with(operation: :finalized)
+ end
+ end
+
+ context 'when it is a heartbeat request' do
+ let(:params) { { state: 'success' } }
+
+ it 'updates a build timestamp' do
+ expect { subject.execute }.to change { build.updated_at }
+ end
+ end
+
+ context 'when request payload carries a trace' do
+ let(:params) { { state: 'success', trace: 'overwritten' } }
+
+ it 'overwrites a trace' do
+ result = subject.execute
+
+ expect(build.trace.raw).to eq 'overwritten'
+ expect(result.status).to eq 200
+ end
+
+ it 'updates overwrite operation metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :overwrite)
+ end
+ end
+
+ context 'when state is unknown' do
+ let(:params) { { state: 'unknown' } }
+
+ it 'responds with 400 bad request' do
+ result = subject.execute
+
+ expect(result.status).to eq 400
+ expect(build).to be_running
+ end
+ end
+ end
+
+ context 'when build has a checksum' do
+ let(:params) do
+ { checksum: 'crc32:12345678', state: 'failed', failure_reason: 'script_failure' }
+ end
+
+ context 'when build trace has been migrated' do
+ before do
+ create(:ci_build_trace_chunk, :database_with_data, build: build)
+ end
+
+ it 'updates a build state' do
+ subject.execute
+
+ expect(build).to be_failed
+ end
+
+ it 'responds with 200 OK status' do
+ result = subject.execute
+
+ expect(result.status).to eq 200
+ end
+
+ it 'increments trace finalized operation metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :finalized)
+ end
+ end
+
+ context 'when build trace has not been migrated yet' do
+ before do
+ create(:ci_build_trace_chunk, :redis_with_data, build: build)
+ end
+
+ it 'does not update a build state' do
+ subject.execute
+
+ expect(build).to be_running
+ end
+
+ it 'responds with 202 accepted' do
+ result = subject.execute
+
+ expect(result.status).to eq 202
+ end
+
+ it 'schedules live chunks for migration' do
+ expect(Ci::BuildTraceChunkFlushWorker)
+ .to receive(:perform_async)
+ .with(build.trace_chunks.first.id)
+
+ subject.execute
+ end
+
+ it 'increments trace accepted operation metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :accepted)
+ end
+
+ it 'creates a pending state record' do
+ subject.execute
+
+ build.pending_state.then do |status|
+ expect(status).to be_present
+ expect(status.state).to eq 'failed'
+ expect(status.trace_checksum).to eq 'crc32:12345678'
+ expect(status.failure_reason).to eq 'script_failure'
+ end
+ end
+
+ context 'when build pending state is outdated' do
+ before do
+ build.create_pending_state(
+ state: 'failed',
+ trace_checksum: 'crc32:12345678',
+ failure_reason: 'script_failure',
+ created_at: 10.minutes.ago
+ )
+ end
+
+ it 'responds with 200 OK' do
+ result = subject.execute
+
+ expect(result.status).to eq 200
+ end
+
+ it 'updates build state' do
+ subject.execute
+
+ expect(build.reload).to be_failed
+ expect(build.failure_reason).to eq 'script_failure'
+ end
+
+ it 'increments discarded traces metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :discarded)
+ end
+
+ it 'does not increment finalized trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .not_to have_received(:increment_trace_operation)
+ .with(operation: :finalized)
+ end
+ end
+
+ context 'when build pending state has changes' do
+ before do
+ build.create_pending_state(
+ state: 'success',
+ created_at: 10.minutes.ago
+ )
+ end
+
+ it 'uses stored state and responds with 200 OK' do
+ result = subject.execute
+
+ expect(result.status).to eq 200
+ end
+
+ it 'increments conflict trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :conflict)
+ end
+ end
+
+ context 'when live traces are disabled' do
+ before do
+ stub_feature_flags(ci_enable_live_trace: false)
+ end
+
+ it 'responds with 200 OK' do
+ result = subject.execute
+
+ expect(result.status).to eq 200
+ end
+ end
+ end
+ end
+
+ def execute_with_stubbed_metrics!
+ described_class
+ .new(build, params, metrics)
+ .execute
+ end
+end
diff --git a/spec/services/ci/update_runner_service_spec.rb b/spec/services/ci/update_runner_service_spec.rb
index cad9e893335..1c875b2f54a 100644
--- a/spec/services/ci/update_runner_service_spec.rb
+++ b/spec/services/ci/update_runner_service_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe Ci::UpdateRunnerService do
end
def update
- described_class.new(runner).update(params)
+ described_class.new(runner).update(params) # rubocop: disable Rails/SaveBang
end
end
end
diff --git a/spec/services/clusters/applications/schedule_update_service_spec.rb b/spec/services/clusters/applications/schedule_update_service_spec.rb
index f559fb1b7aa..01a75a334e6 100644
--- a/spec/services/clusters/applications/schedule_update_service_spec.rb
+++ b/spec/services/clusters/applications/schedule_update_service_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Clusters::Applications::ScheduleUpdateService do
let(:project) { create(:project) }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
context 'when application is able to be updated' do
diff --git a/spec/services/clusters/aws/provision_service_spec.rb b/spec/services/clusters/aws/provision_service_spec.rb
index 529e1d26575..52612e5ac40 100644
--- a/spec/services/clusters/aws/provision_service_spec.rb
+++ b/spec/services/clusters/aws/provision_service_spec.rb
@@ -22,6 +22,7 @@ RSpec.describe Clusters::Aws::ProvisionService do
[
{ parameter_key: 'ClusterName', parameter_value: provider.cluster.name },
{ parameter_key: 'ClusterRole', parameter_value: provider.role_arn },
+ { parameter_key: 'KubernetesVersion', parameter_value: provider.kubernetes_version },
{ parameter_key: 'ClusterControlPlaneSecurityGroup', parameter_value: provider.security_group_id },
{ parameter_key: 'VpcId', parameter_value: provider.vpc_id },
{ parameter_key: 'Subnets', parameter_value: provider.subnet_ids.join(',') },
diff --git a/spec/services/deployments/after_create_service_spec.rb b/spec/services/deployments/after_create_service_spec.rb
index 3287eed03b7..6cdb4c88191 100644
--- a/spec/services/deployments/after_create_service_spec.rb
+++ b/spec/services/deployments/after_create_service_spec.rb
@@ -122,7 +122,7 @@ RSpec.describe Deployments::AfterCreateService do
end
it 'renews auto stop at' do
- Timecop.freeze do
+ freeze_time do
environment.update!(auto_stop_at: nil)
expect { subject.execute }
diff --git a/spec/services/design_management/move_designs_service_spec.rb b/spec/services/design_management/move_designs_service_spec.rb
index a05518dc28d..a43f0a2f805 100644
--- a/spec/services/design_management/move_designs_service_spec.rb
+++ b/spec/services/design_management/move_designs_service_spec.rb
@@ -32,30 +32,6 @@ RSpec.describe DesignManagement::MoveDesignsService do
describe '#execute' do
subject { service.execute }
- context 'the feature is unavailable' do
- let(:current_design) { designs.first }
- let(:previous_design) { designs.second }
- let(:next_design) { designs.third }
-
- before do
- stub_feature_flags(reorder_designs: false)
- end
-
- it 'raises cannot_move' do
- expect(subject).to be_error.and(have_attributes(message: :cannot_move))
- end
-
- context 'but it is available on the current project' do
- before do
- stub_feature_flags(reorder_designs: issue.project)
- end
-
- it 'is successful' do
- expect(subject).to be_success
- end
- end
- end
-
context 'the user cannot move designs' do
let(:current_design) { designs.first }
let(:current_user) { build_stubbed(:user) }
@@ -124,7 +100,7 @@ RSpec.describe DesignManagement::MoveDesignsService do
expect(subject).to be_success
- expect(issue.designs.ordered(issue.project)).to eq([
+ expect(issue.designs.ordered).to eq([
# Existing designs which already had a relative_position set.
# These should stay at the beginning, in the same order.
other_design1,
diff --git a/spec/services/error_tracking/list_projects_service_spec.rb b/spec/services/error_tracking/list_projects_service_spec.rb
index 8bc632349fa..ce391bd1ca0 100644
--- a/spec/services/error_tracking/list_projects_service_spec.rb
+++ b/spec/services/error_tracking/list_projects_service_spec.rb
@@ -121,7 +121,7 @@ RSpec.describe ErrorTracking::ListProjectsService do
end
context 'error_tracking_setting is nil' do
- let(:error_tracking_setting) { build(:project_error_tracking_setting) }
+ let(:error_tracking_setting) { build(:project_error_tracking_setting, project: project) }
let(:new_api_url) { new_api_host + 'api/0/projects/org/proj/' }
before do
diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb
index a91519a710f..3c67c15f10a 100644
--- a/spec/services/event_create_service_spec.rb
+++ b/spec/services/event_create_service_spec.rb
@@ -8,6 +8,16 @@ RSpec.describe EventCreateService do
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 }
+
+ expect { subject }
+ .to change { Gitlab::UsageDataCounters::TrackUniqueEvents.count_unique_events(tracking_params) }
+ .by(1)
+ end
+ end
+
describe 'Issues' do
describe '#open_issue' do
let(:issue) { create(:issue) }
@@ -40,34 +50,52 @@ RSpec.describe EventCreateService do
end
end
- describe 'Merge Requests' do
+ describe 'Merge Requests', :clean_gitlab_redis_shared_state do
describe '#open_mr' do
+ subject(:open_mr) { service.open_mr(merge_request, merge_request.author) }
+
let(:merge_request) { create(:merge_request) }
- it { expect(service.open_mr(merge_request, merge_request.author)).to be_truthy }
+ it { expect(open_mr).to be_truthy }
it "creates new event" do
- expect { service.open_mr(merge_request, merge_request.author) }.to change { Event.count }
+ expect { open_mr }.to change { Event.count }
+ end
+
+ it_behaves_like "it records the event in the event counter" do
+ let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION }
end
end
describe '#close_mr' do
+ subject(:close_mr) { service.close_mr(merge_request, merge_request.author) }
+
let(:merge_request) { create(:merge_request) }
- it { expect(service.close_mr(merge_request, merge_request.author)).to be_truthy }
+ it { expect(close_mr).to be_truthy }
it "creates new event" do
- expect { service.close_mr(merge_request, merge_request.author) }.to change { Event.count }
+ expect { close_mr }.to change { Event.count }
+ end
+
+ it_behaves_like "it records the event in the event counter" do
+ let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION }
end
end
describe '#merge_mr' do
+ subject(:merge_mr) { service.merge_mr(merge_request, merge_request.author) }
+
let(:merge_request) { create(:merge_request) }
- it { expect(service.merge_mr(merge_request, merge_request.author)).to be_truthy }
+ it { expect(merge_mr).to be_truthy }
it "creates new event" do
- expect { service.merge_mr(merge_request, merge_request.author) }.to change { Event.count }
+ expect { merge_mr }.to change { Event.count }
+ end
+
+ it_behaves_like "it records the event in the event counter" do
+ let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION }
end
end
@@ -180,6 +208,8 @@ RSpec.describe EventCreateService do
where(:action) { Event::WIKI_ACTIONS.map { |action| [action] } }
with_them do
+ subject { create_event }
+
it 'creates the event' do
expect(create_event).to have_attributes(
wiki_page?: true,
@@ -201,13 +231,8 @@ RSpec.describe EventCreateService do
expect(duplicate).to eq(event)
end
- it 'records the event in the event counter' do
- counter_class = Gitlab::UsageDataCounters::TrackUniqueActions
- tracking_params = { event_action: counter_class::WIKI_ACTION, date_from: Date.yesterday, date_to: Date.today }
-
- expect { create_event }
- .to change { counter_class.count_unique(tracking_params) }
- .by(1)
+ it_behaves_like "it records the event in the event counter" do
+ let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::WIKI_ACTION }
end
end
@@ -242,13 +267,8 @@ RSpec.describe EventCreateService do
it_behaves_like 'service for creating a push event', PushEventPayloadService
- it 'records the event in the event counter' do
- counter_class = Gitlab::UsageDataCounters::TrackUniqueActions
- tracking_params = { event_action: counter_class::PUSH_ACTION, date_from: Date.yesterday, date_to: Date.today }
-
- expect { subject }
- .to change { counter_class.count_unique(tracking_params) }
- .from(0).to(1)
+ it_behaves_like "it records the event in the event counter" do
+ let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::PUSH_ACTION }
end
end
@@ -265,13 +285,8 @@ RSpec.describe EventCreateService do
it_behaves_like 'service for creating a push event', BulkPushEventPayloadService
- it 'records the event in the event counter' do
- counter_class = Gitlab::UsageDataCounters::TrackUniqueActions
- tracking_params = { event_action: counter_class::PUSH_ACTION, date_from: Date.yesterday, date_to: Date.today }
-
- expect { subject }
- .to change { counter_class.count_unique(tracking_params) }
- .from(0).to(1)
+ it_behaves_like "it records the event in the event counter" do
+ let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::PUSH_ACTION }
end
end
@@ -299,7 +314,7 @@ RSpec.describe EventCreateService do
let_it_be(:updated) { create_list(:design, 5) }
let_it_be(:created) { create_list(:design, 3) }
- let(:result) { service.save_designs(author, create: created, update: updated) }
+ subject(:result) { service.save_designs(author, create: created, update: updated) }
specify { expect { result }.to change { Event.count }.by(8) }
@@ -319,13 +334,8 @@ RSpec.describe EventCreateService do
expect(events.map(&:design)).to match_array(updated)
end
- it 'records the event in the event counter' do
- counter_class = Gitlab::UsageDataCounters::TrackUniqueActions
- tracking_params = { event_action: counter_class::DESIGN_ACTION, date_from: Date.yesterday, date_to: Date.today }
-
- expect { result }
- .to change { counter_class.count_unique(tracking_params) }
- .from(0).to(1)
+ it_behaves_like "it records the event in the event counter" do
+ let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION }
end
end
@@ -333,7 +343,7 @@ RSpec.describe EventCreateService do
let_it_be(:designs) { create_list(:design, 5) }
let_it_be(:author) { create(:user) }
- let(:result) { service.destroy_designs(designs, author) }
+ subject(:result) { service.destroy_designs(designs, author) }
specify { expect { result }.to change { Event.count }.by(5) }
@@ -346,13 +356,37 @@ RSpec.describe EventCreateService do
expect(events.map(&:design)).to match_array(designs)
end
- it 'records the event in the event counter' do
- counter_class = Gitlab::UsageDataCounters::TrackUniqueActions
- tracking_params = { event_action: counter_class::DESIGN_ACTION, date_from: Date.yesterday, date_to: Date.today }
+ it_behaves_like "it records the event in the event counter" do
+ let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION }
+ end
+ end
+ end
+
+ describe '#leave_note' do
+ subject(:leave_note) { service.leave_note(note, author) }
+
+ let(:note) { create(:note) }
+ let(:author) { create(:user) }
+ let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION }
+
+ it { expect(leave_note).to be_truthy }
+
+ it "creates new event" do
+ expect { leave_note }.to change { Event.count }.by(1)
+ end
+
+ context 'when it is a diff note' do
+ it_behaves_like "it records the event in the event counter" do
+ let(:note) { create(:diff_note_on_merge_request) }
+ end
+ end
+
+ 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 }
- expect { result }
- .to change { counter_class.count_unique(tracking_params) }
- .from(0).to(1)
+ expect { subject }.not_to change { counter_class.count_unique_events(tracking_params) }
end
end
end
diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb
index 7f22af8bfc6..db25bb766c9 100644
--- a/spec/services/git/branch_hooks_service_spec.rb
+++ b/spec/services/git/branch_hooks_service_spec.rb
@@ -348,7 +348,7 @@ RSpec.describe Git::BranchHooksService do
context 'when the project is forked', :sidekiq_might_not_need_inline do
let(:upstream_project) { project }
- let(:forked_project) { fork_project(upstream_project, user, repository: true) }
+ let(:forked_project) { fork_project(upstream_project, user, repository: true, using_service: true) }
let!(:forked_service) do
described_class.new(forked_project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref })
diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb
index 6ccf2d03e4a..5d73794c1ec 100644
--- a/spec/services/git/branch_push_service_spec.rb
+++ b/spec/services/git/branch_push_service_spec.rb
@@ -416,6 +416,7 @@ RSpec.describe Git::BranchPushService, services: true do
before do
# project.create_jira_service doesn't seem to invalidate the cache here
project.has_external_issue_tracker = true
+ stub_jira_service_test
jira_service_settings
stub_jira_urls("JIRA-1")
@@ -703,4 +704,68 @@ RSpec.describe Git::BranchPushService, services: true do
service.execute
service
end
+
+ context 'Jira Connect hooks' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:branch_to_sync) { nil }
+ let(:commits_to_sync) { [] }
+ let(:params) do
+ { change: { oldrev: oldrev, newrev: newrev, ref: ref } }
+ end
+
+ subject do
+ described_class.new(project, user, params)
+ end
+
+ shared_examples 'enqueues Jira sync worker' do
+ specify do
+ Sidekiq::Testing.fake! do
+ expect(JiraConnect::SyncBranchWorker).to receive(:perform_async)
+ .with(project.id, branch_to_sync, commits_to_sync)
+ .and_call_original
+
+ expect { subject.execute }.to change(JiraConnect::SyncBranchWorker.jobs, :size).by(1)
+ end
+ end
+ end
+
+ shared_examples 'does not enqueue Jira sync worker' do
+ specify do
+ Sidekiq::Testing.fake! do
+ expect { subject.execute }.not_to change(JiraConnect::SyncBranchWorker.jobs, :size)
+ end
+ end
+ end
+
+ context 'with a Jira subscription' do
+ before do
+ create(:jira_connect_subscription, namespace: project.namespace)
+ end
+
+ context 'branch name contains Jira issue key' do
+ let(:branch_to_sync) { 'branch-JIRA-123' }
+ let(:ref) { "refs/heads/#{branch_to_sync}" }
+
+ it_behaves_like 'enqueues Jira sync worker'
+ end
+
+ context 'commit message contains Jira issue key' do
+ let(:commits_to_sync) { [newrev] }
+
+ before do
+ allow_any_instance_of(Commit).to receive(:safe_message).and_return('Commit with key JIRA-123')
+ end
+
+ it_behaves_like 'enqueues Jira sync worker'
+ end
+
+ context 'branch name and commit message does not contain Jira issue key' do
+ it_behaves_like 'does not enqueue Jira sync worker'
+ end
+ end
+
+ context 'without a Jira subscription' do
+ it_behaves_like 'does not enqueue Jira sync worker'
+ end
+ end
end
diff --git a/spec/services/git/wiki_push_service_spec.rb b/spec/services/git/wiki_push_service_spec.rb
index 7f709be8593..816f20f0bc3 100644
--- a/spec/services/git/wiki_push_service_spec.rb
+++ b/spec/services/git/wiki_push_service_spec.rb
@@ -6,12 +6,20 @@ RSpec.describe Git::WikiPushService, services: true do
include RepoHelpers
let_it_be(:key_id) { create(:key, user: current_user).shell_id }
- let_it_be(:project) { create(:project, :wiki_repo) }
- let_it_be(:current_user) { create(:user) }
- let_it_be(:git_wiki) { project.wiki.wiki }
- let_it_be(:repository) { git_wiki.repository }
+ let_it_be(:wiki) { create(:project_wiki) }
+ let_it_be(:current_user) { wiki.container.default_owner }
+ let_it_be(:git_wiki) { wiki.wiki }
+ let_it_be(:repository) { wiki.repository }
describe '#execute' do
+ it 'executes model-specific callbacks' do
+ expect(wiki).to receive(:after_post_receive)
+
+ create_service(current_sha).execute
+ end
+ end
+
+ describe '#process_changes' do
context 'the push contains more than the permitted number of changes' do
def run_service
process_changes { described_class::MAX_CHANGES.succ.times { write_new_page } }
@@ -37,8 +45,8 @@ RSpec.describe Git::WikiPushService, services: true do
let(:count) { Event::WIKI_ACTIONS.size }
def run_service
- wiki_page_a = create(:wiki_page, project: project)
- wiki_page_b = create(:wiki_page, project: project)
+ wiki_page_a = create(:wiki_page, wiki: wiki)
+ wiki_page_b = create(:wiki_page, wiki: wiki)
process_changes do
write_new_page
@@ -135,7 +143,7 @@ RSpec.describe Git::WikiPushService, services: true do
end
context 'when a page we already know about has been updated' do
- let(:wiki_page) { create(:wiki_page, project: project) }
+ let(:wiki_page) { create(:wiki_page, wiki: wiki) }
before do
create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page)
@@ -165,7 +173,7 @@ RSpec.describe Git::WikiPushService, services: true do
context 'when a page we do not know about has been updated' do
def run_service
- wiki_page = create(:wiki_page, project: project)
+ wiki_page = create(:wiki_page, wiki: wiki)
process_changes { update_page(wiki_page.title) }
end
@@ -189,7 +197,7 @@ RSpec.describe Git::WikiPushService, services: true do
context 'when a page we do not know about has been deleted' do
def run_service
- wiki_page = create(:wiki_page, project: project)
+ wiki_page = create(:wiki_page, wiki: wiki)
process_changes { delete_page(wiki_page.page.path) }
end
@@ -254,9 +262,9 @@ RSpec.describe Git::WikiPushService, services: true do
it_behaves_like 'a no-op push'
- context 'but is enabled for a given project' do
+ context 'but is enabled for a given container' do
before do
- stub_feature_flags(wiki_events_on_git_push: project)
+ stub_feature_flags(wiki_events_on_git_push: wiki.container)
end
it 'creates events' do
@@ -280,19 +288,19 @@ RSpec.describe Git::WikiPushService, services: true do
def create_service(base, refs = ['refs/heads/master'])
changes = post_received(base, refs).changes
- described_class.new(project, current_user, changes: changes)
+ described_class.new(wiki, current_user, changes: changes)
end
def post_received(base, refs)
change_str = refs.map { |ref| +"#{base} #{current_sha} #{ref}" }.join("\n")
- post_received = ::Gitlab::GitPostReceive.new(project, key_id, change_str, {})
+ post_received = ::Gitlab::GitPostReceive.new(wiki.container, key_id, change_str, {})
allow(post_received).to receive(:identify).with(key_id).and_return(current_user)
post_received
end
def current_sha
- repository.gitaly_ref_client.find_branch('master')&.dereferenced_target&.id || Gitlab::Git::BLANK_SHA
+ repository.commit('master')&.id || Gitlab::Git::BLANK_SHA
end
# It is important not to re-use the WikiPage services here, since they create
@@ -312,7 +320,7 @@ RSpec.describe Git::WikiPushService, services: true do
file_content: 'some stuff',
branch_name: 'master'
}
- ::Wikis::CreateAttachmentService.new(container: project, current_user: project.owner, params: params).execute
+ ::Wikis::CreateAttachmentService.new(container: wiki.container, current_user: current_user, params: params).execute
end
def update_page(title)
diff --git a/spec/services/ci/web_ide_config_service_spec.rb b/spec/services/ide/base_config_service_spec.rb
index 437b468cec8..debdc6e5809 100644
--- a/spec/services/ci/web_ide_config_service_spec.rb
+++ b/spec/services/ide/base_config_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::WebIdeConfigService do
+RSpec.describe Ide::BaseConfigService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let(:sha) { 'sha' }
@@ -47,44 +47,6 @@ RSpec.describe Ci::WebIdeConfigService do
message: "Invalid configuration format")
end
end
-
- context 'content is valid, but terminal not defined' do
- let(:config_content) { '{}' }
-
- it 'returns success' do
- is_expected.to include(
- status: :success,
- terminal: nil)
- end
- end
-
- context 'content is valid, with enabled terminal' do
- let(:config_content) { 'terminal: {}' }
-
- it 'returns success' do
- is_expected.to include(
- status: :success,
- terminal: {
- tag_list: [],
- yaml_variables: [],
- options: { script: ["sleep 60"] }
- })
- end
- end
-
- context 'content is valid, with custom terminal' do
- let(:config_content) { 'terminal: { before_script: [ls] }' }
-
- it 'returns success' do
- is_expected.to include(
- status: :success,
- terminal: {
- tag_list: [],
- yaml_variables: [],
- options: { before_script: ["ls"], script: ["sleep 60"] }
- })
- end
- end
end
end
end
diff --git a/spec/services/ide/schemas_config_service_spec.rb b/spec/services/ide/schemas_config_service_spec.rb
new file mode 100644
index 00000000000..19e5ca9e87d
--- /dev/null
+++ b/spec/services/ide/schemas_config_service_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ide::SchemasConfigService do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let(:filename) { 'sample.yml' }
+ let(:schema_content) { double(body: '{"title":"Sample schema"}') }
+
+ describe '#execute' do
+ before do
+ project.add_developer(user)
+
+ allow(Gitlab::HTTP).to receive(:get).with(anything) do
+ schema_content
+ end
+ end
+
+ subject { described_class.new(project, user, filename: filename).execute }
+
+ context 'feature flag schema_linting is enabled', unless: Gitlab.ee? do
+ before do
+ stub_feature_flags(schema_linting: true)
+ end
+
+ context 'when no predefined schema exists for the given filename' do
+ it 'returns an empty object' do
+ is_expected.to include(
+ status: :success,
+ schema: {})
+ end
+ end
+
+ context 'when a predefined schema exists for the given filename' do
+ let(:filename) { '.gitlab-ci.yml' }
+
+ it 'uses predefined schema matches' do
+ expect(Gitlab::HTTP).to receive(:get).with('https://json.schemastore.org/gitlab-ci')
+ expect(subject[:schema]['title']).to eq "Sample schema"
+ end
+ end
+ end
+
+ context 'feature flag schema_linting is disabled', unless: Gitlab.ee? do
+ it 'returns an empty object' do
+ is_expected.to include(
+ status: :success,
+ schema: {})
+ end
+ end
+ end
+end
diff --git a/spec/services/ide/terminal_config_service_spec.rb b/spec/services/ide/terminal_config_service_spec.rb
new file mode 100644
index 00000000000..d6c4f7a2a69
--- /dev/null
+++ b/spec/services/ide/terminal_config_service_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ide::TerminalConfigService do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let(:sha) { 'sha' }
+
+ describe '#execute' do
+ subject { described_class.new(project, user, sha: sha).execute }
+
+ before do
+ project.add_developer(user)
+
+ allow(project.repository).to receive(:blob_data_at).with('sha', anything) do
+ config_content
+ end
+ end
+
+ context 'content is not valid' do
+ let(:config_content) { 'invalid content' }
+
+ it 'returns an error' do
+ is_expected.to include(
+ status: :error,
+ message: "Invalid configuration format")
+ end
+ end
+
+ context 'terminal not defined' do
+ let(:config_content) { '{}' }
+
+ it 'returns success' do
+ is_expected.to include(
+ status: :success,
+ terminal: nil)
+ end
+ end
+
+ context 'terminal enabled' do
+ let(:config_content) { 'terminal: {}' }
+
+ it 'returns success' do
+ is_expected.to include(
+ status: :success,
+ terminal: {
+ tag_list: [],
+ yaml_variables: [],
+ options: { script: ["sleep 60"] }
+ })
+ end
+ end
+
+ context 'custom terminal enabled' do
+ let(:config_content) { 'terminal: { before_script: [ls] }' }
+
+ it 'returns success' do
+ is_expected.to include(
+ status: :success,
+ terminal: {
+ tag_list: [],
+ yaml_variables: [],
+ options: { before_script: ["ls"], script: ["sleep 60"] }
+ })
+ end
+ end
+ end
+end
diff --git a/spec/services/incident_management/create_incident_label_service_spec.rb b/spec/services/incident_management/create_incident_label_service_spec.rb
index 18a7c019497..4771dfc9e64 100644
--- a/spec/services/incident_management/create_incident_label_service_spec.rb
+++ b/spec/services/incident_management/create_incident_label_service_spec.rb
@@ -10,9 +10,10 @@ RSpec.describe IncidentManagement::CreateIncidentLabelService do
subject(:execute) { service.execute }
describe 'execute' do
- let(:title) { described_class::LABEL_PROPERTIES[:title] }
- let(:color) { described_class::LABEL_PROPERTIES[:color] }
- let(:description) { described_class::LABEL_PROPERTIES[:description] }
+ let(:incident_label_attributes) { attributes_for(:label, :incident) }
+ let(:title) { incident_label_attributes[:title] }
+ let(:color) { incident_label_attributes[:color] }
+ let(:description) { incident_label_attributes[:description] }
shared_examples 'existing label' do
it 'returns the existing label' do
diff --git a/spec/services/incident_management/incidents/create_service_spec.rb b/spec/services/incident_management/incidents/create_service_spec.rb
index 404c428cd94..1330f3ae033 100644
--- a/spec/services/incident_management/incidents/create_service_spec.rb
+++ b/spec/services/incident_management/incidents/create_service_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe IncidentManagement::Incidents::CreateService do
context 'when incident has title and description' do
let(:title) { 'Incident title' }
let(:new_issue) { Issue.last! }
- let(:label_title) { IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title] }
+ let(:label_title) { attributes_for(:label, :incident)[:title] }
it 'responds with success' do
expect(create_incident).to be_success
@@ -23,14 +23,47 @@ RSpec.describe IncidentManagement::Incidents::CreateService do
expect { create_incident }.to change(Issue, :count).by(1)
end
- it 'created issue has correct attributes' do
+ it 'created issue has correct attributes', :aggregate_failures do
create_incident
- aggregate_failures do
- expect(new_issue.title).to eq(title)
- expect(new_issue.description).to eq(description)
- expect(new_issue.author).to eq(user)
- expect(new_issue.issue_type).to eq('incident')
- expect(new_issue.labels.map(&:title)).to eq([label_title])
+
+ expect(new_issue.title).to eq(title)
+ expect(new_issue.description).to eq(description)
+ expect(new_issue.author).to eq(user)
+ end
+
+ it_behaves_like 'incident issue' do
+ before do
+ create_incident
+ end
+
+ let(:issue) { new_issue }
+ end
+
+ context 'with default severity' do
+ it 'sets the correct severity level to "unknown"' do
+ create_incident
+ expect(new_issue.severity).to eq(IssuableSeverity::DEFAULT)
+ end
+ end
+
+ context 'with severity' do
+ using RSpec::Parameterized::TableSyntax
+
+ subject(:create_incident) { described_class.new(project, user, title: title, description: description, severity: severity).execute }
+
+ where(:severity, :incident_severity) do
+ 'critical' | 'critical'
+ 'high' | 'high'
+ 'medium' | 'medium'
+ 'low' | 'low'
+ 'unknown' | 'unknown'
+ end
+
+ with_them do
+ it 'sets the correct severity level' do
+ create_incident
+ expect(new_issue.severity).to eq(incident_severity)
+ 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 daf4f68208e..217550542bb 100644
--- a/spec/services/issuable/common_system_notes_service_spec.rb
+++ b/spec/services/issuable/common_system_notes_service_spec.rb
@@ -32,17 +32,6 @@ RSpec.describe Issuable::CommonSystemNotesService do
end
end
- context 'when new milestone is assigned' do
- before do
- milestone = create(:milestone, project: project)
- issuable.milestone_id = milestone.id
-
- stub_feature_flags(track_resource_milestone_change_events: false)
- end
-
- it_behaves_like 'system note creation', {}, 'changed milestone'
- end
-
context 'with merge requests Draft note' do
context 'adding Draft note' do
let(:issuable) { create(:merge_request, title: "merge request") }
@@ -100,32 +89,10 @@ RSpec.describe Issuable::CommonSystemNotesService do
expect(event.user_id).to eq user.id
end
- context 'when milestone change event tracking is disabled' do
- before do
- stub_feature_flags(track_resource_milestone_change_events: false)
-
- issuable.milestone = create(:milestone, project: project)
- issuable.save
- end
-
- it 'creates a system note for milestone set' do
- expect { subject }.to change { issuable.notes.count }.from(0).to(1)
- expect(issuable.notes.last.note).to match('changed milestone')
- end
-
- it 'does not create a milestone change event' do
- expect { subject }.not_to change { ResourceMilestoneEvent.count }
- end
- end
-
- context 'when milestone change event tracking is enabled' do
+ context 'when changing milestones' do
let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:issuable) { create(:issue, project: project, milestone: milestone) }
- before do
- stub_feature_flags(track_resource_milestone_change_events: true)
- end
-
it 'does not create a system note for milestone set' do
expect { subject }.not_to change { issuable.notes.count }
end
diff --git a/spec/services/issue_links/create_service_spec.rb b/spec/services/issue_links/create_service_spec.rb
new file mode 100644
index 00000000000..873890d25cf
--- /dev/null
+++ b/spec/services/issue_links/create_service_spec.rb
@@ -0,0 +1,184 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssueLinks::CreateService do
+ describe '#execute' do
+ let(:namespace) { create :namespace }
+ let(:project) { create :project, namespace: namespace }
+ let(:issue) { create :issue, project: project }
+ let(:user) { create :user }
+ let(:params) do
+ {}
+ end
+
+ before do
+ project.add_developer(user)
+ end
+
+ subject { described_class.new(issue, user, params).execute }
+
+ context 'when the reference list is empty' do
+ let(:params) do
+ { issuable_references: [] }
+ end
+
+ it 'returns error' do
+ is_expected.to eq(message: 'No Issue found for given params', status: :error, http_status: 404)
+ end
+ end
+
+ context 'when Issue not found' do
+ let(:params) do
+ { issuable_references: ["##{non_existing_record_iid}"] }
+ end
+
+ it 'returns error' do
+ is_expected.to eq(message: 'No Issue found for given params', status: :error, http_status: 404)
+ end
+
+ it 'no relationship is created' do
+ expect { subject }.not_to change(IssueLink, :count)
+ end
+ end
+
+ context 'when user has no permission to target project Issue' do
+ let(:target_issuable) { create :issue }
+
+ let(:params) do
+ { issuable_references: [target_issuable.to_reference(project)] }
+ end
+
+ it 'returns error' do
+ target_issuable.project.add_guest(user)
+
+ is_expected.to eq(message: 'No Issue found for given params', status: :error, http_status: 404)
+ end
+
+ it 'no relationship is created' do
+ expect { subject }.not_to change(IssueLink, :count)
+ end
+ end
+
+ context 'source and target are the same issue' do
+ let(:params) do
+ { issuable_references: [issue.to_reference] }
+ end
+
+ it 'does not create notes' do
+ expect(SystemNoteService).not_to receive(:relate_issue)
+
+ subject
+ end
+
+ it 'no relationship is created' do
+ expect { subject }.not_to change(IssueLink, :count)
+ end
+ end
+
+ context 'when there is an issue to relate' do
+ let(:issue_a) { create :issue, project: project }
+ let(:another_project) { create :project, namespace: project.namespace }
+ let(:another_project_issue) { create :issue, project: another_project }
+
+ let(:issue_a_ref) { issue_a.to_reference }
+ let(:another_project_issue_ref) { another_project_issue.to_reference(project) }
+
+ let(:params) do
+ { issuable_references: [issue_a_ref, another_project_issue_ref] }
+ end
+
+ before do
+ another_project.add_developer(user)
+ end
+
+ it 'creates relationships' do
+ expect { subject }.to change(IssueLink, :count).from(0).to(2)
+
+ expect(IssueLink.find_by!(target: issue_a)).to have_attributes(source: issue, link_type: 'relates_to')
+ expect(IssueLink.find_by!(target: another_project_issue)).to have_attributes(source: issue, link_type: 'relates_to')
+ end
+
+ it 'returns success status' do
+ is_expected.to eq(status: :success)
+ end
+
+ it 'creates notes' do
+ # First two-way relation notes
+ expect(SystemNoteService).to receive(:relate_issue)
+ .with(issue, issue_a, user)
+ expect(SystemNoteService).to receive(:relate_issue)
+ .with(issue_a, issue, user)
+
+ # Second two-way relation notes
+ expect(SystemNoteService).to receive(:relate_issue)
+ .with(issue, another_project_issue, user)
+ expect(SystemNoteService).to receive(:relate_issue)
+ .with(another_project_issue, issue, user)
+
+ subject
+ end
+
+ context 'issue is an incident' do
+ let(:issue) { create(:incident, project: project) }
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_relate do
+ let(:current_user) { user }
+ end
+ end
+ end
+
+ context 'when reference of any already related issue is present' do
+ let(:issue_a) { create :issue, project: project }
+ let(:issue_b) { create :issue, project: project }
+ let(:issue_c) { create :issue, project: project }
+
+ before do
+ create :issue_link, source: issue, target: issue_b, link_type: IssueLink::TYPE_RELATES_TO
+ create :issue_link, source: issue, target: issue_c, link_type: IssueLink::TYPE_RELATES_TO
+ end
+
+ let(:params) do
+ {
+ issuable_references: [
+ issue_a.to_reference,
+ issue_b.to_reference,
+ issue_c.to_reference
+ ],
+ link_type: IssueLink::TYPE_RELATES_TO
+ }
+ end
+
+ it 'creates notes only for new relations' do
+ expect(SystemNoteService).to receive(:relate_issue).with(issue, issue_a, anything)
+ expect(SystemNoteService).to receive(:relate_issue).with(issue_a, issue, anything)
+ expect(SystemNoteService).not_to receive(:relate_issue).with(issue, issue_b, anything)
+ expect(SystemNoteService).not_to receive(:relate_issue).with(issue_b, issue, anything)
+ expect(SystemNoteService).not_to receive(:relate_issue).with(issue, issue_c, anything)
+ expect(SystemNoteService).not_to receive(:relate_issue).with(issue_c, issue, anything)
+
+ subject
+ end
+ end
+
+ context 'when there are invalid references' do
+ let(:issue_a) { create :issue, project: project }
+
+ let(:params) do
+ { issuable_references: [issue.to_reference, issue_a.to_reference] }
+ end
+
+ it 'creates links only for valid references' do
+ expect { subject }.to change { IssueLink.count }.by(1)
+ end
+
+ it 'returns error status' do
+ expect(subject).to eq(
+ status: :error,
+ http_status: 422,
+ message: "#{issue.to_reference} cannot be added: cannot be related to itself"
+ )
+ end
+ end
+ end
+end
diff --git a/spec/services/issue_links/destroy_service_spec.rb b/spec/services/issue_links/destroy_service_spec.rb
new file mode 100644
index 00000000000..f441629f892
--- /dev/null
+++ b/spec/services/issue_links/destroy_service_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssueLinks::DestroyService do
+ describe '#execute' do
+ let(:project) { create(:project_empty_repo) }
+ let(:user) { create(:user) }
+
+ subject { described_class.new(issue_link, user).execute }
+
+ context 'when successfully removes an issue link' do
+ let(:issue_a) { create(:issue, project: project) }
+ let(:issue_b) { create(:issue, project: project) }
+
+ let!(:issue_link) { create(:issue_link, source: issue_a, target: issue_b) }
+
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'removes related issue' do
+ expect { subject }.to change(IssueLink, :count).from(1).to(0)
+ end
+
+ it 'creates notes' do
+ # Two-way notes creation
+ expect(SystemNoteService).to receive(:unrelate_issue)
+ .with(issue_link.source, issue_link.target, user)
+ expect(SystemNoteService).to receive(:unrelate_issue)
+ .with(issue_link.target, issue_link.source, user)
+
+ subject
+ end
+
+ it 'returns success message' do
+ is_expected.to eq(message: 'Relation was removed', status: :success)
+ end
+
+ context 'target is an incident' do
+ let(:issue_b) { create(:incident, project: project) }
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_unrelate do
+ let(:current_user) { user }
+ end
+ end
+ end
+
+ context 'when failing to remove an issue link' do
+ let(:unauthorized_project) { create(:project) }
+ let(:issue_a) { create(:issue, project: project) }
+ let(:issue_b) { create(:issue, project: unauthorized_project) }
+
+ let!(:issue_link) { create(:issue_link, source: issue_a, target: issue_b) }
+
+ it 'does not remove relation' do
+ expect { subject }.not_to change(IssueLink, :count).from(1)
+ end
+
+ it 'does not create notes' do
+ expect(SystemNoteService).not_to receive(:unrelate_issue)
+ end
+
+ it 'returns error message' do
+ is_expected.to eq(message: 'No Issue Link found', status: :error, http_status: 404)
+ end
+ end
+ end
+end
diff --git a/spec/services/issue_links/list_service_spec.rb b/spec/services/issue_links/list_service_spec.rb
new file mode 100644
index 00000000000..7a3ba845c7c
--- /dev/null
+++ b/spec/services/issue_links/list_service_spec.rb
@@ -0,0 +1,194 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssueLinks::ListService do
+ let(:user) { create :user }
+ let(:project) { create(:project_empty_repo, :private) }
+ let(:issue) { create :issue, project: project }
+ let(:user_role) { :developer }
+
+ before do
+ project.add_role(user, user_role)
+ end
+
+ describe '#execute' do
+ subject { described_class.new(issue, user).execute }
+
+ context 'user can see all issues' do
+ let(:issue_b) { create :issue, project: project }
+ let(:issue_c) { create :issue, project: project }
+ let(:issue_d) { create :issue, project: project }
+
+ let!(:issue_link_c) do
+ create(:issue_link, source: issue_d,
+ target: issue)
+ end
+
+ let!(:issue_link_b) do
+ create(:issue_link, source: issue,
+ target: issue_c)
+ end
+
+ let!(:issue_link_a) do
+ create(:issue_link, source: issue,
+ target: issue_b)
+ end
+
+ it 'ensures no N+1 queries are made' do
+ control_count = ActiveRecord::QueryRecorder.new { subject }.count
+
+ project = create :project, :public
+ milestone = create :milestone, project: project
+ issue_x = create :issue, project: project, milestone: milestone
+ issue_y = create :issue, project: project, assignees: [user]
+ issue_z = create :issue, project: project
+ create :issue_link, source: issue_x, target: issue_y
+ create :issue_link, source: issue_x, target: issue_z
+ create :issue_link, source: issue_y, target: issue_z
+
+ expect { subject }.not_to exceed_query_limit(control_count)
+ end
+
+ it 'returns related issues JSON' do
+ expect(subject.size).to eq(3)
+
+ expect(subject).to include(include(id: issue_b.id,
+ title: issue_b.title,
+ state: issue_b.state,
+ reference: issue_b.to_reference(project),
+ path: "/#{project.full_path}/-/issues/#{issue_b.iid}",
+ relation_path: "/#{project.full_path}/-/issues/#{issue.iid}/links/#{issue_link_a.id}"))
+
+ expect(subject).to include(include(id: issue_c.id,
+ title: issue_c.title,
+ state: issue_c.state,
+ reference: issue_c.to_reference(project),
+ path: "/#{project.full_path}/-/issues/#{issue_c.iid}",
+ relation_path: "/#{project.full_path}/-/issues/#{issue.iid}/links/#{issue_link_b.id}"))
+
+ expect(subject).to include(include(id: issue_d.id,
+ title: issue_d.title,
+ state: issue_d.state,
+ reference: issue_d.to_reference(project),
+ path: "/#{project.full_path}/-/issues/#{issue_d.iid}",
+ relation_path: "/#{project.full_path}/-/issues/#{issue.iid}/links/#{issue_link_c.id}"))
+ end
+ end
+
+ context 'referencing a public project issue' do
+ let(:public_project) { create :project, :public }
+ let(:issue_b) { create :issue, project: public_project }
+
+ let!(:issue_link) do
+ create(:issue_link, source: issue, target: issue_b)
+ end
+
+ it 'presents issue' do
+ expect(subject.size).to eq(1)
+ end
+ end
+
+ context 'referencing issue with removed relationships' do
+ context 'when referenced a deleted issue' do
+ let(:issue_b) { create :issue, project: project }
+ let!(:issue_link) do
+ create(:issue_link, source: issue, target: issue_b)
+ end
+
+ it 'ignores issue' do
+ issue_b.destroy!
+
+ is_expected.to eq([])
+ end
+ end
+
+ context 'when referenced an issue with deleted project' do
+ let(:issue_b) { create :issue, project: project }
+ let!(:issue_link) do
+ create(:issue_link, source: issue, target: issue_b)
+ end
+
+ it 'ignores issue' do
+ project.destroy!
+
+ is_expected.to eq([])
+ end
+ end
+
+ context 'when referenced an issue with deleted namespace' do
+ let(:issue_b) { create :issue, project: project }
+ let!(:issue_link) do
+ create(:issue_link, source: issue, target: issue_b)
+ end
+
+ it 'ignores issue' do
+ project.namespace.destroy!
+
+ is_expected.to eq([])
+ end
+ end
+ end
+
+ context 'user cannot see relations' do
+ context 'when user cannot see the referenced issue' do
+ let!(:issue_link) do
+ create(:issue_link, source: issue)
+ end
+
+ it 'returns an empty list' do
+ is_expected.to eq([])
+ end
+ end
+
+ context 'when user cannot see the issue that referenced' do
+ let!(:issue_link) do
+ create(:issue_link, target: issue)
+ end
+
+ it 'returns an empty list' do
+ is_expected.to eq([])
+ end
+ end
+ end
+
+ context 'remove relations' do
+ let!(:issue_link) do
+ create(:issue_link, source: issue, target: referenced_issue)
+ end
+
+ context 'user can admin related issues just on target project' do
+ let(:user_role) { :guest }
+ let(:target_project) { create :project }
+ let(:referenced_issue) { create :issue, project: target_project }
+
+ it 'returns no destroy relation path' do
+ target_project.add_developer(user)
+
+ expect(subject.first[:relation_path]).to be_nil
+ end
+ end
+
+ context 'user can admin related issues just on source project' do
+ let(:user_role) { :developer }
+ let(:target_project) { create :project }
+ let(:referenced_issue) { create :issue, project: target_project }
+
+ it 'returns no destroy relation path' do
+ target_project.add_guest(user)
+
+ expect(subject.first[:relation_path]).to be_nil
+ end
+ end
+
+ context 'when user can admin related issues on both projects' do
+ let(:referenced_issue) { create :issue, project: project }
+
+ it 'returns related issue destroy relation path' do
+ expect(subject.first[:relation_path])
+ .to eq("/#{project.full_path}/-/issues/#{issue.iid}/links/#{issue_link.id}")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/issue_rebalancing_service_spec.rb b/spec/services/issue_rebalancing_service_spec.rb
new file mode 100644
index 00000000000..94f594c8083
--- /dev/null
+++ b/spec/services/issue_rebalancing_service_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssueRebalancingService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { project.creator }
+ let_it_be(:start) { RelativePositioning::START_POSITION }
+ let_it_be(:max_pos) { RelativePositioning::MAX_POSITION }
+ let_it_be(:min_pos) { RelativePositioning::MIN_POSITION }
+ let_it_be(:clump_size) { 300 }
+
+ let_it_be(:unclumped) do
+ (0..clump_size).to_a.map do |i|
+ create(:issue, project: project, author: user, relative_position: start + (1024 * i))
+ end
+ end
+
+ let_it_be(:end_clump) do
+ (0..clump_size).to_a.map do |i|
+ create(:issue, project: project, author: user, relative_position: max_pos - i)
+ end
+ end
+
+ let_it_be(:start_clump) do
+ (0..clump_size).to_a.map do |i|
+ create(:issue, project: project, author: user, relative_position: min_pos + i)
+ end
+ end
+
+ def issues_in_position_order
+ project.reload.issues.reorder(relative_position: :asc).to_a
+ end
+
+ it 'rebalances a set of issues with clumps at the end and start' do
+ all_issues = start_clump + unclumped + end_clump.reverse
+ service = described_class.new(project.issues.first)
+
+ expect { service.execute }.not_to change { issues_in_position_order.map(&:id) }
+
+ all_issues.each(&:reset)
+
+ gaps = all_issues.take(all_issues.count - 1).zip(all_issues.drop(1)).map do |a, b|
+ b.relative_position - a.relative_position
+ end
+
+ expect(gaps).to all(be > RelativePositioning::MIN_GAP)
+ expect(all_issues.first.relative_position).to be > (RelativePositioning::MIN_POSITION * 0.9999)
+ expect(all_issues.last.relative_position).to be < (RelativePositioning::MAX_POSITION * 0.9999)
+ end
+
+ it 'is idempotent' do
+ service = described_class.new(project.issues.first)
+
+ expect do
+ service.execute
+ service.execute
+ end.not_to change { issues_in_position_order.map(&:id) }
+ end
+
+ it 'does nothing if the feature flag is disabled' do
+ stub_feature_flags(rebalance_issues: false)
+ issue = project.issues.first
+ issue.project
+ issue.project.group
+ old_pos = issue.relative_position
+
+ service = described_class.new(issue)
+
+ expect { service.execute }.not_to exceed_query_limit(0)
+ expect(old_pos).to eq(issue.reload.relative_position)
+ end
+
+ it 'acts if the flag is enabled for the project' do
+ issue = create(:issue, project: project, author: user, relative_position: max_pos)
+ stub_feature_flags(rebalance_issues: issue.project)
+
+ service = described_class.new(issue)
+
+ expect { service.execute }.to change { issue.reload.relative_position }
+ end
+
+ it 'acts if the flag is enabled for the group' do
+ issue = create(:issue, project: project, author: user, relative_position: max_pos)
+ project.update!(group: create(:group))
+ stub_feature_flags(rebalance_issues: issue.project.group)
+
+ service = described_class.new(issue)
+
+ expect { service.execute }.to change { issue.reload.relative_position }
+ end
+
+ it 'aborts if there are too many issues' do
+ issue = project.issues.first
+ base = double(count: 10_001)
+
+ allow(Issue).to receive(:relative_positioning_query_base).with(issue).and_return(base)
+
+ expect { described_class.new(issue).execute }.to raise_error(described_class::TooManyIssues)
+ end
+end
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index 7ca7d3be99c..4db6e5cac12 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -67,6 +67,15 @@ RSpec.describe Issues::CloseService do
service.execute(issue)
end
+
+ context 'issue is incident type' do
+ let(:issue) { create(:incident, project: project) }
+ let(:current_user) { user }
+
+ subject { service.execute(issue) }
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_closed
+ end
end
describe '#close_issue' do
@@ -288,7 +297,7 @@ RSpec.describe Issues::CloseService do
end
it 'deletes milestone issue counters cache' do
- issue.update(milestone: create(:milestone, project: project))
+ issue.update!(milestone: create(:milestone, project: project))
expect_next_instance_of(Milestones::ClosedIssuesCountService, issue.milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index fdf2326b75e..e09a7faece5 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -3,18 +3,18 @@
require 'spec_helper'
RSpec.describe Issues::CreateService do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
+ let_it_be_with_reload(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
describe '#execute' do
+ let_it_be(:assignee) { create(:user) }
+ let_it_be(:milestone) { create(:milestone, project: project) }
let(:issue) { described_class.new(project, user, opts).execute }
- let(:assignee) { create(:user) }
- let(:milestone) { create(:milestone, project: project) }
context 'when params are valid' do
- let(:labels) { create_pair(:label, project: project) }
+ let_it_be(:labels) { create_pair(:label, project: project) }
- before do
+ before_all do
project.add_maintainer(user)
project.add_maintainer(assignee)
end
@@ -29,6 +29,8 @@ RSpec.describe Issues::CreateService do
end
it 'creates the issue with the given params' do
+ expect(Issuable::CommonSystemNotesService).to receive_message_chain(:new, :execute)
+
expect(issue).to be_persisted
expect(issue.title).to eq('Awesome issue')
expect(issue.assignees).to eq [assignee]
@@ -37,14 +39,55 @@ RSpec.describe Issues::CreateService do
expect(issue.due_date).to eq Date.tomorrow
end
+ context 'when skip_system_notes is true' do
+ let(:issue) { described_class.new(project, user, opts).execute(skip_system_notes: true) }
+
+ it 'does not call Issuable::CommonSystemNotesService' do
+ expect(Issuable::CommonSystemNotesService).not_to receive(:new)
+
+ issue
+ end
+ end
+
+ it_behaves_like 'not an incident issue'
+
+ context 'issue is incident type' do
+ before do
+ opts.merge!(issue_type: 'incident')
+ end
+
+ let(:current_user) { user }
+ let(:incident_label_attributes) { attributes_for(:label, :incident) }
+
+ subject { issue }
+
+ it_behaves_like 'incident issue'
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_created
+
+ it 'does create an incident label' do
+ expect { subject }
+ .to change { Label.where(incident_label_attributes).count }.by(1)
+ end
+
+ context 'when invalid' do
+ before do
+ opts.merge!(title: '')
+ end
+
+ it 'does not create an incident label prematurely' do
+ expect { subject }.not_to change(Label, :count)
+ end
+ end
+ end
+
it 'refreshes the number of open issues', :use_clean_rails_memory_store_caching do
expect { issue }.to change { project.open_issues_count }.from(0).to(1)
end
context 'when current user cannot admin issues in the project' do
- let(:guest) { create(:user) }
+ let_it_be(:guest) { create(:user) }
- before do
+ before_all do
project.add_guest(guest)
end
@@ -75,6 +118,12 @@ RSpec.describe Issues::CreateService do
expect(Todo.where(attributes).count).to eq 1
end
+ it 'moves the issue to the end, in an asynchronous worker' do
+ expect(IssuePlacementWorker).to receive(:perform_async).with(be_nil, Integer)
+
+ described_class.new(project, user, opts).execute
+ end
+
context 'when label belongs to project group' do
let(:group) { create(:group) }
let(:group_labels) { create_pair(:group_label, group: group) }
@@ -88,7 +137,7 @@ RSpec.describe Issues::CreateService do
end
before do
- project.update(group: group)
+ project.update!(group: group)
end
it 'assigns group labels' do
@@ -233,7 +282,7 @@ RSpec.describe Issues::CreateService do
context 'issue create service' do
context 'assignees' do
- before do
+ before_all do
project.add_maintainer(user)
end
@@ -264,7 +313,7 @@ RSpec.describe Issues::CreateService do
context "when issuable feature is private" do
before do
- project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE,
+ project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE,
merge_requests_access_level: ProjectFeature::PRIVATE)
end
@@ -272,7 +321,7 @@ RSpec.describe Issues::CreateService do
levels.each do |level|
it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
- project.update(visibility_level: level)
+ project.update!(visibility_level: level)
opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] }
issue = described_class.new(project, user, opts).execute
@@ -299,7 +348,7 @@ RSpec.describe Issues::CreateService do
}
end
- before do
+ before_all do
project.add_maintainer(user)
project.add_maintainer(assignee)
end
@@ -313,11 +362,11 @@ RSpec.describe Issues::CreateService do
end
context 'resolving discussions' do
- let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
- let(:merge_request) { discussion.noteable }
- let(:project) { merge_request.source_project }
+ let_it_be(:discussion) { create(:diff_note_on_merge_request).to_discussion }
+ let_it_be(:merge_request) { discussion.noteable }
+ let_it_be(:project) { merge_request.source_project }
- before do
+ before_all do
project.add_maintainer(user)
end
diff --git a/spec/services/issues/duplicate_service_spec.rb b/spec/services/issues/duplicate_service_spec.rb
index 78e030e6ac7..0b5bc3f32ef 100644
--- a/spec/services/issues/duplicate_service_spec.rb
+++ b/spec/services/issues/duplicate_service_spec.rb
@@ -83,6 +83,17 @@ RSpec.describe Issues::DuplicateService do
expect(duplicate_issue.reload.duplicated_to).to eq(canonical_issue)
end
+
+ it 'relates the duplicate issues' do
+ canonical_project.add_reporter(user)
+ duplicate_project.add_reporter(user)
+
+ subject.execute(duplicate_issue, canonical_issue)
+
+ issue_link = IssueLink.last
+ expect(issue_link.source).to eq(duplicate_issue)
+ expect(issue_link.target).to eq(canonical_issue)
+ end
end
end
end
diff --git a/spec/services/issues/export_csv_service_spec.rb b/spec/services/issues/export_csv_service_spec.rb
index 76381fe525b..8072b7a478e 100644
--- a/spec/services/issues/export_csv_service_spec.rb
+++ b/spec/services/issues/export_csv_service_spec.rb
@@ -38,8 +38,8 @@ RSpec.describe Issues::ExportCsvService do
before do
# Creating a timelog touches the updated_at timestamp of issue,
# so create these first.
- issue.timelogs.create(time_spent: 360, user: user)
- issue.timelogs.create(time_spent: 200, user: user)
+ issue.timelogs.create!(time_spent: 360, user: user)
+ issue.timelogs.create!(time_spent: 200, user: user)
issue.update!(milestone: milestone,
assignees: [user],
description: 'Issue with details',
diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb
index 5f944d1213b..c2989dc86cf 100644
--- a/spec/services/issues/move_service_spec.rb
+++ b/spec/services/issues/move_service_spec.rb
@@ -101,6 +101,41 @@ RSpec.describe Issues::MoveService do
end
end
+ context 'issue with milestone' do
+ let(:milestone) { create(:milestone, group: sub_group_1) }
+ let(:new_project) { create(:project, namespace: sub_group_1) }
+
+ let(:old_issue) do
+ create(:issue, title: title, description: description, project: old_project, author: author, milestone: milestone)
+ end
+
+ before do
+ create(:resource_milestone_event, issue: old_issue, milestone: milestone, action: :add)
+ end
+
+ it 'does not create extra milestone events' do
+ new_issue = move_service.execute(old_issue, new_project)
+
+ expect(new_issue.resource_milestone_events.count).to eq(old_issue.resource_milestone_events.count)
+ end
+ end
+
+ context 'issue with due date' do
+ let(:old_issue) do
+ create(:issue, title: title, description: description, project: old_project, author: author, due_date: '2020-01-10')
+ end
+
+ before do
+ SystemNoteService.change_due_date(old_issue, old_project, author, old_issue.due_date)
+ end
+
+ it 'does not create extra system notes' do
+ new_issue = move_service.execute(old_issue, new_project)
+
+ expect(new_issue.notes.count).to eq(old_issue.notes.count)
+ end
+ end
+
context 'issue with assignee' do
let_it_be(:assignee) { create(:user) }
@@ -223,6 +258,45 @@ RSpec.describe Issues::MoveService do
end
end
+ describe '#rewrite_related_issues' do
+ include_context 'user can move issue'
+
+ let(:admin) { create(:admin) }
+ let(:authorized_project) { create(:project) }
+ let(:authorized_project2) { create(:project) }
+ let(:unauthorized_project) { create(:project) }
+
+ let(:authorized_issue_b) { create(:issue, project: authorized_project) }
+ let(:authorized_issue_c) { create(:issue, project: authorized_project2) }
+ let(:authorized_issue_d) { create(:issue, project: authorized_project2) }
+ let(:unauthorized_issue) { create(:issue, project: unauthorized_project) }
+
+ let!(:issue_link_a) { create(:issue_link, source: old_issue, target: authorized_issue_b) }
+ let!(:issue_link_b) { create(:issue_link, source: old_issue, target: unauthorized_issue) }
+ let!(:issue_link_c) { create(:issue_link, source: old_issue, target: authorized_issue_c) }
+ let!(:issue_link_d) { create(:issue_link, source: authorized_issue_d, target: old_issue) }
+
+ before do
+ authorized_project.add_developer(user)
+ authorized_project2.add_developer(user)
+ end
+
+ context 'multiple related issues' do
+ it 'moves all related issues and retains permissions' do
+ new_issue = move_service.execute(old_issue, new_project)
+
+ expect(new_issue.related_issues(admin))
+ .to match_array([authorized_issue_b, authorized_issue_c, authorized_issue_d, unauthorized_issue])
+
+ expect(new_issue.related_issues(user))
+ .to match_array([authorized_issue_b, authorized_issue_c, authorized_issue_d])
+
+ expect(authorized_issue_d.related_issues(user))
+ .to match_array([new_issue])
+ end
+ end
+ end
+
context 'updating sent notifications' do
let!(:old_issue_notification_1) { create(:sent_notification, project: old_issue.project, noteable: old_issue) }
let!(:old_issue_notification_2) { create(:sent_notification, project: old_issue.project, noteable: old_issue) }
diff --git a/spec/services/issues/related_branches_service_spec.rb b/spec/services/issues/related_branches_service_spec.rb
index d79132d98db..1780023803a 100644
--- a/spec/services/issues/related_branches_service_spec.rb
+++ b/spec/services/issues/related_branches_service_spec.rb
@@ -57,7 +57,7 @@ RSpec.describe Issues::RelatedBranchesService do
unreadable_branch_name => unreadable_pipeline
}.each do |name, pipeline|
allow(repo).to receive(:find_branch).with(name).and_return(make_branch)
- allow(project).to receive(:pipeline_for).with(name, sha).and_return(pipeline)
+ allow(project).to receive(:latest_pipeline).with(name, sha).and_return(pipeline)
end
allow(repo).to receive(:find_branch).with(missing_branch).and_return(nil)
diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb
index f7416203259..ffe74cca9cf 100644
--- a/spec/services/issues/reopen_service_spec.rb
+++ b/spec/services/issues/reopen_service_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe Issues::ReopenService do
end
it 'deletes milestone issue counters cache' do
- issue.update(milestone: create(:milestone, project: project))
+ issue.update!(milestone: create(:milestone, project: project))
expect_next_instance_of(Milestones::ClosedIssuesCountService, issue.milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
@@ -53,6 +53,15 @@ RSpec.describe Issues::ReopenService do
described_class.new(project, user).execute(issue)
end
+ context 'issue is incident type' do
+ let(:issue) { create(:incident, :closed, project: project) }
+ let(:current_user) { user }
+
+ subject { described_class.new(project, user).execute(issue) }
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_reopened
+ end
+
context 'when issue is not confidential' do
it 'executes issue hooks' do
expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks)
diff --git a/spec/services/issues/reorder_service_spec.rb b/spec/services/issues/reorder_service_spec.rb
index b6ad488a48c..78b937a1caf 100644
--- a/spec/services/issues/reorder_service_spec.rb
+++ b/spec/services/issues/reorder_service_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe Issues::ReorderService do
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create_default(:user) }
let_it_be(:group) { create(:group) }
+ let_it_be(:project, reload: true) { create(:project, namespace: group) }
shared_examples 'issues reorder service' do
context 'when reordering issues' do
@@ -14,7 +14,7 @@ RSpec.describe Issues::ReorderService do
end
it 'returns false with both invalid params' do
- params = { move_after_id: nil, move_before_id: 1 }
+ params = { move_after_id: nil, move_before_id: non_existing_record_id }
expect(service(params).execute(issue1)).to be_falsey
end
@@ -27,27 +27,39 @@ RSpec.describe Issues::ReorderService do
expect(issue1.relative_position)
.to be_between(issue2.relative_position, issue3.relative_position)
end
+
+ it 'sorts issues if only given one neighbour, on the left' do
+ params = { move_before_id: issue3.id }
+
+ service(params).execute(issue1)
+
+ expect(issue1.relative_position).to be > issue3.relative_position
+ end
+
+ it 'sorts issues if only given one neighbour, on the right' do
+ params = { move_after_id: issue1.id }
+
+ service(params).execute(issue3)
+
+ expect(issue3.relative_position).to be < issue1.relative_position
+ end
end
end
describe '#execute' do
- let(:issue1) { create(:issue, project: project, relative_position: 10) }
- let(:issue2) { create(:issue, project: project, relative_position: 20) }
- let(:issue3) { create(:issue, project: project, relative_position: 30) }
+ let_it_be(:issue1, reload: true) { create(:issue, project: project, relative_position: 10) }
+ let_it_be(:issue2) { create(:issue, project: project, relative_position: 20) }
+ let_it_be(:issue3, reload: true) { create(:issue, project: project, relative_position: 30) }
context 'when ordering issues in a project' do
- let(:parent) { project }
-
before do
- parent.add_developer(user)
+ project.add_developer(user)
end
it_behaves_like 'issues reorder service'
end
context 'when ordering issues in a group' do
- let(:project) { create(:project, namespace: group) }
-
before do
group.add_developer(user)
end
diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb
index a541d92feb2..9fbc9cbcca6 100644
--- a/spec/services/issues/resolve_discussions_spec.rb
+++ b/spec/services/issues/resolve_discussions_spec.rb
@@ -79,7 +79,7 @@ RSpec.describe Issues::ResolveDiscussions do
noteable: merge_request,
project: merge_request.target_project,
line_number: 15
- )])
+ )])
service = DummyService.new(
project,
user,
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index 42452e95f6b..f0092c35fda 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -52,7 +52,8 @@ RSpec.describe Issues::UpdateService, :mailer do
state_event: 'close',
label_ids: [label.id],
due_date: Date.tomorrow,
- discussion_locked: true
+ discussion_locked: true,
+ severity: 'low'
}
end
@@ -71,6 +72,51 @@ RSpec.describe Issues::UpdateService, :mailer do
expect(issue.discussion_locked).to be_truthy
end
+ context 'when issue type is not incident' do
+ it 'returns default severity' do
+ update_issue(opts)
+
+ expect(issue.severity).to eq(IssuableSeverity::DEFAULT)
+ end
+
+ it_behaves_like 'not an incident issue' do
+ before do
+ update_issue(opts)
+ end
+ end
+ end
+
+ context 'when issue type is incident' do
+ let(:issue) { create(:incident, project: project) }
+
+ it 'changes updates the severity' do
+ update_issue(opts)
+
+ expect(issue.severity).to eq('low')
+ end
+
+ it_behaves_like 'incident issue' do
+ before do
+ update_issue(opts)
+ end
+ end
+
+ context 'with existing incident label' do
+ let_it_be(:incident_label) { create(:label, :incident, project: project) }
+
+ before do
+ opts.delete(:label_ids) # don't override but retain existing labels
+ issue.labels << incident_label
+ end
+
+ it_behaves_like 'incident issue' do
+ before do
+ update_issue(opts)
+ end
+ end
+ end
+ end
+
it 'refreshes the number of open issues when the issue is made confidential', :use_clean_rails_memory_store_caching do
issue # make sure the issue is created first so our counts are correct.
@@ -93,6 +139,40 @@ RSpec.describe Issues::UpdateService, :mailer do
update_issue(confidential: false)
end
+ context 'issue in incident type' do
+ let(:current_user) { user }
+ let(:incident_label_attributes) { attributes_for(:label, :incident) }
+
+ before do
+ opts.merge!(issue_type: 'incident', confidential: true)
+ end
+
+ subject { update_issue(opts) }
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_change_confidential
+
+ it_behaves_like 'incident issue' do
+ before do
+ subject
+ end
+ end
+
+ it 'does create an incident label' do
+ expect { subject }
+ .to change { Label.where(incident_label_attributes).count }.by(1)
+ end
+
+ context 'when invalid' do
+ before do
+ opts.merge!(title: '')
+ end
+
+ it 'does not create an incident label prematurely' do
+ expect { subject }.not_to change(Label, :count)
+ end
+ end
+ end
+
it 'updates open issue counter for assignees when issue is reassigned' do
update_issue(assignee_ids: [user2.id])
@@ -106,7 +186,7 @@ RSpec.describe Issues::UpdateService, :mailer do
[issue, issue1, issue2].each do |issue|
issue.move_to_end
- issue.save
+ issue.save!
end
opts[:move_between_ids] = [issue1.id, issue2.id]
@@ -116,6 +196,66 @@ RSpec.describe Issues::UpdateService, :mailer do
expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
end
+ it 'does not rebalance even if needed if the flag is disabled' do
+ stub_feature_flags(rebalance_issues: false)
+
+ range = described_class::NO_REBALANCING_NEEDED
+ issue1 = create(:issue, project: project, relative_position: range.first - 100)
+ issue2 = create(:issue, project: project, relative_position: range.first)
+ issue.update!(relative_position: RelativePositioning::START_POSITION)
+
+ opts[:move_between_ids] = [issue1.id, issue2.id]
+
+ expect(IssueRebalancingWorker).not_to receive(:perform_async)
+
+ update_issue(opts)
+ expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
+ end
+
+ it 'rebalances if needed if the flag is enabled for the project' do
+ stub_feature_flags(rebalance_issues: project)
+
+ range = described_class::NO_REBALANCING_NEEDED
+ issue1 = create(:issue, project: project, relative_position: range.first - 100)
+ issue2 = create(:issue, project: project, relative_position: range.first)
+ issue.update!(relative_position: RelativePositioning::START_POSITION)
+
+ opts[:move_between_ids] = [issue1.id, issue2.id]
+
+ expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project.id)
+
+ update_issue(opts)
+ expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
+ end
+
+ it 'rebalances if needed on the left' do
+ range = described_class::NO_REBALANCING_NEEDED
+ issue1 = create(:issue, project: project, relative_position: range.first - 100)
+ issue2 = create(:issue, project: project, relative_position: range.first)
+ issue.update!(relative_position: RelativePositioning::START_POSITION)
+
+ opts[:move_between_ids] = [issue1.id, issue2.id]
+
+ expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project.id)
+
+ update_issue(opts)
+ expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
+ end
+
+ it 'rebalances if needed on the right' do
+ range = described_class::NO_REBALANCING_NEEDED
+ issue1 = create(:issue, project: project, relative_position: range.last)
+ issue2 = create(:issue, project: project, relative_position: range.last + 100)
+ issue.update!(relative_position: RelativePositioning::START_POSITION)
+
+ opts[:move_between_ids] = [issue1.id, issue2.id]
+
+ expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project.id)
+
+ update_issue(opts)
+ expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
+ end
+
context 'when moving issue between issues from different projects' do
let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) }
@@ -294,7 +434,7 @@ RSpec.describe Issues::UpdateService, :mailer do
end
it 'does not update assignee_id with unauthorized users' do
- project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
update_issue(confidential: true)
non_member = create(:user)
original_assignees = issue.assignees
@@ -382,13 +522,16 @@ RSpec.describe Issues::UpdateService, :mailer do
expect(Todo.where(attributes).count).to eq(1)
end
- end
- context 'when the milestone is removed' do
- before do
- stub_feature_flags(track_resource_milestone_change_events: false)
+ context 'issue is incident type' do
+ let(:issue) { create(:incident, project: project) }
+ let(:current_user) { user }
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_assigned
end
+ end
+ context 'when the milestone is removed' do
let!(:non_subscriber) { create(:user) }
let!(:subscriber) do
@@ -398,12 +541,10 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
- it_behaves_like 'system notes for milestones'
-
it 'sends notifications for subscribers of changed milestone', :sidekiq_might_not_need_inline do
issue.milestone = create(:milestone, project: project)
- issue.save
+ issue.save!
perform_enqueued_jobs do
update_issue(milestone_id: "")
@@ -416,7 +557,7 @@ RSpec.describe Issues::UpdateService, :mailer do
it 'clears milestone issue counters cache' do
issue.milestone = create(:milestone, project: project)
- issue.save
+ issue.save!
expect_next_instance_of(Milestones::IssuesCountService, issue.milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
@@ -430,10 +571,6 @@ RSpec.describe Issues::UpdateService, :mailer do
end
context 'when the milestone is assigned' do
- before do
- stub_feature_flags(track_resource_milestone_change_events: false)
- end
-
let!(:non_subscriber) { create(:user) }
let!(:subscriber) do
@@ -449,8 +586,6 @@ RSpec.describe Issues::UpdateService, :mailer do
expect(todo.reload.done?).to eq true
end
- it_behaves_like 'system notes for milestones'
-
it 'sends notifications for subscribers of changed milestone', :sidekiq_might_not_need_inline do
perform_enqueued_jobs do
update_issue(milestone: create(:milestone, project: project))
@@ -670,7 +805,7 @@ RSpec.describe Issues::UpdateService, :mailer do
let(:params) { { label_ids: [label.id], add_label_ids: [label3.id] } }
before do
- issue.update(labels: [label2])
+ issue.update!(labels: [label2])
end
it 'replaces the labels with the ones in label_ids and adds those in add_label_ids' do
@@ -682,7 +817,7 @@ RSpec.describe Issues::UpdateService, :mailer do
let(:params) { { label_ids: [label.id, label2.id, label3.id], remove_label_ids: [label.id] } }
before do
- issue.update(labels: [label, label3])
+ issue.update!(labels: [label, label3])
end
it 'replaces the labels with the ones in label_ids and removes those in remove_label_ids' do
@@ -694,7 +829,7 @@ RSpec.describe Issues::UpdateService, :mailer do
let(:params) { { add_label_ids: [label3.id], remove_label_ids: [label.id] } }
before do
- issue.update(labels: [label])
+ issue.update!(labels: [label])
end
it 'adds the passed labels' do
@@ -711,7 +846,7 @@ RSpec.describe Issues::UpdateService, :mailer do
context 'for a label assigned to an issue' do
it 'removes the label' do
- issue.update(labels: [label])
+ issue.update!(labels: [label])
expect(result.label_ids).to be_empty
end
@@ -760,7 +895,7 @@ 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
assignee = create(:user)
- project.update(visibility_level: level)
+ project.update!(visibility_level: level)
feature_visibility_attr = :"#{issue.model_name.plural}_access_level"
project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE)
diff --git a/spec/services/issues/zoom_link_service_spec.rb b/spec/services/issues/zoom_link_service_spec.rb
index 56aec4fe564..b095cb24212 100644
--- a/spec/services/issues/zoom_link_service_spec.rb
+++ b/spec/services/issues/zoom_link_service_spec.rb
@@ -82,6 +82,13 @@ RSpec.describe Issues::ZoomLinkService do
include_examples 'can add meeting'
+ context 'issue is incident type' do
+ let(:issue) { create(:incident) }
+ let(:current_user) { user }
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_zoom_meeting
+ end
+
context 'with insufficient issue update permissions' do
include_context 'insufficient issue update permissions'
include_examples 'cannot add meeting'
diff --git a/spec/services/jira/requests/projects/list_service_spec.rb b/spec/services/jira/requests/projects/list_service_spec.rb
index b4db77f8104..415dd42c795 100644
--- a/spec/services/jira/requests/projects/list_service_spec.rb
+++ b/spec/services/jira/requests/projects/list_service_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe Jira::Requests::Projects::ListService do
expect(client).to receive(:get).and_return([{ 'key' => 'pr1', 'name' => 'First Project' }, { 'key' => 'pr2', 'name' => 'Second Project' }])
end
- it 'returns a paylod with Jira projets' do
+ it 'returns a paylod with Jira projects' do
payload = subject.payload
expect(subject.success?).to be_truthy
@@ -80,7 +80,7 @@ RSpec.describe Jira::Requests::Projects::ListService do
context 'when filtering projects by name' do
let(:params) { { query: 'first' } }
- it 'returns a paylod with Jira projets' do
+ it 'returns a paylod with Jira procjets' do
payload = subject.payload
expect(subject.success?).to be_truthy
diff --git a/spec/services/jira_connect/sync_service_spec.rb b/spec/services/jira_connect/sync_service_spec.rb
new file mode 100644
index 00000000000..e26ca30d0e1
--- /dev/null
+++ b/spec/services/jira_connect/sync_service_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SyncService do
+ describe '#execute' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:branches) { [project.repository.find_branch('master')] }
+ let(:commits) { project.commits_by(oids: %w[b83d6e3 5a62481]) }
+ let(:merge_requests) { [create(:merge_request, source_project: project, target_project: project)] }
+
+ subject do
+ described_class.new(project).execute(commits: commits, branches: branches, merge_requests: merge_requests)
+ end
+
+ before do
+ create(:jira_connect_subscription, namespace: project.namespace)
+ end
+
+ def expect_jira_client_call(return_value = { 'status': 'success' })
+ expect_next_instance_of(Atlassian::JiraConnect::Client) do |instance|
+ expect(instance).to receive(:store_dev_info).with(
+ project: project,
+ commits: commits,
+ branches: [instance_of(Gitlab::Git::Branch)],
+ merge_requests: merge_requests
+ ).and_return(return_value)
+ end
+ end
+
+ def expect_log(type, message)
+ expect(Gitlab::ProjectServiceLogger)
+ .to receive(type).with(
+ message: 'response from jira dev_info api',
+ integration: 'JiraConnect',
+ project_id: project.id,
+ project_path: project.full_path,
+ jira_response: message&.to_json
+ )
+ end
+
+ it 'calls Atlassian::JiraConnect::Client#store_dev_info and logs the response' do
+ expect_jira_client_call
+
+ expect_log(:info, { 'status': 'success' })
+
+ subject
+ end
+
+ context 'when request returns an error' do
+ it 'logs the response as an error' do
+ expect_jira_client_call({
+ 'errorMessages' => ['some error message']
+ })
+
+ expect_log(:error, { 'errorMessages' => ['some error message'] })
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/services/jira_connect_subscriptions/create_service_spec.rb b/spec/services/jira_connect_subscriptions/create_service_spec.rb
new file mode 100644
index 00000000000..77e758cf6fe
--- /dev/null
+++ b/spec/services/jira_connect_subscriptions/create_service_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnectSubscriptions::CreateService do
+ let(:installation) { create(:jira_connect_installation) }
+ let(:current_user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:path) { group.full_path }
+
+ subject { described_class.new(installation, current_user, namespace_path: path).execute }
+
+ before do
+ group.add_maintainer(current_user)
+ end
+
+ shared_examples 'a failed execution' do
+ it 'does not create a subscription' do
+ expect { subject }.not_to change { installation.subscriptions.count }
+ end
+
+ it 'returns an error status' do
+ expect(subject[:status]).to eq(:error)
+ end
+ end
+
+ context 'when user does have access' do
+ it 'creates a subscription' do
+ expect { subject }.to change { installation.subscriptions.count }.from(0).to(1)
+ end
+
+ it 'returns success' do
+ expect(subject[:status]).to eq(:success)
+ end
+ end
+
+ context 'when path is invalid' do
+ let(:path) { 'some_invalid_namespace_path' }
+
+ it_behaves_like 'a failed execution'
+ end
+
+ context 'when user does not have access' do
+ subject { described_class.new(installation, create(:user), namespace_path: path).execute }
+
+ it_behaves_like 'a failed execution'
+ end
+end
diff --git a/spec/services/lfs/push_service_spec.rb b/spec/services/lfs/push_service_spec.rb
new file mode 100644
index 00000000000..8e5b98fdc9c
--- /dev/null
+++ b/spec/services/lfs/push_service_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Lfs::PushService do
+ let(:logger) { service.send(:logger) }
+ let(:lfs_client) { service.send(:lfs_client) }
+
+ let_it_be(:project) { create(:forked_project_with_submodules) }
+ let_it_be(:remote_mirror) { create(:remote_mirror, project: project, enabled: true) }
+ let_it_be(:lfs_object) { create_linked_lfs_object(project, :project) }
+
+ let(:params) { { url: remote_mirror.bare_url, credentials: remote_mirror.credentials } }
+
+ subject(:service) { described_class.new(project, nil, params) }
+
+ describe "#execute" do
+ it 'uploads the object when upload is requested' do
+ stub_lfs_batch(lfs_object)
+
+ expect(lfs_client)
+ .to receive(:upload)
+ .with(lfs_object, upload_action_spec(lfs_object), authenticated: true)
+
+ expect(service.execute).to eq(status: :success)
+ end
+
+ it 'does nothing if there are no LFS objects' do
+ lfs_object.destroy!
+
+ expect(lfs_client).not_to receive(:upload)
+
+ expect(service.execute).to eq(status: :success)
+ end
+
+ it 'does not upload the object when upload is not requested' do
+ stub_lfs_batch(lfs_object, upload: false)
+
+ expect(lfs_client).not_to receive(:upload)
+
+ expect(service.execute).to eq(status: :success)
+ end
+
+ it 'returns a failure when submitting a batch fails' do
+ expect(lfs_client).to receive(:batch) { raise 'failed' }
+
+ expect(service.execute).to eq(status: :error, message: 'failed')
+ end
+
+ it 'returns a failure when submitting an upload fails' do
+ stub_lfs_batch(lfs_object)
+ expect(lfs_client).to receive(:upload) { raise 'failed' }
+
+ expect(service.execute).to eq(status: :error, message: 'failed')
+ end
+
+ context 'non-project-repository LFS objects' do
+ let_it_be(:nil_lfs_object) { create_linked_lfs_object(project, nil) }
+ let_it_be(:wiki_lfs_object) { create_linked_lfs_object(project, :wiki) }
+ let_it_be(:design_lfs_object) { create_linked_lfs_object(project, :design) }
+
+ it 'only tries to upload the project-repository LFS object' do
+ stub_lfs_batch(nil_lfs_object, lfs_object, upload: false)
+
+ expect(service.execute).to eq(status: :success)
+ end
+ end
+ end
+
+ def create_linked_lfs_object(project, type)
+ create(:lfs_objects_project, project: project, repository_type: type).lfs_object
+ end
+
+ def stub_lfs_batch(*objects, upload: true)
+ expect(lfs_client)
+ .to receive(:batch).with('upload', containing_exactly(*objects))
+ .and_return('transfer' => 'basic', 'objects' => objects.map { |o| object_spec(o, upload: upload) })
+ end
+
+ def batch_spec(*objects, upload: true)
+ { 'transfer' => 'basic', 'objects' => objects.map {|o| object_spec(o, upload: upload) } }
+ end
+
+ def object_spec(object, upload: true)
+ { 'oid' => object.oid, 'size' => object.size, 'authenticated' => true }.tap do |spec|
+ spec['actions'] = { 'upload' => upload_action_spec(object) } if upload
+ end
+ end
+
+ def upload_action_spec(object)
+ { 'href' => "https://example.com/#{object.oid}/#{object.size}", 'header' => { 'Key' => 'value' } }
+ end
+end
diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb
index 5c90f1f54ea..3b3f2f3b95a 100644
--- a/spec/services/members/destroy_service_spec.rb
+++ b/spec/services/members/destroy_service_spec.rb
@@ -192,8 +192,8 @@ RSpec.describe Members::DestroyService do
context 'with an access requester' do
before do
- group_project.update(request_access_enabled: true)
- group.update(request_access_enabled: true)
+ group_project.update!(request_access_enabled: true)
+ group.update!(request_access_enabled: true)
group_project.request_access(member_user)
group.request_access(member_user)
end
diff --git a/spec/services/merge_requests/base_service_spec.rb b/spec/services/merge_requests/base_service_spec.rb
new file mode 100644
index 00000000000..bb7b70f1ba2
--- /dev/null
+++ b/spec/services/merge_requests/base_service_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::BaseService do
+ include ProjectForksHelper
+
+ let_it_be(:project) { create(:project, :repository) }
+ let(:title) { 'Awesome merge_request' }
+ let(:params) do
+ {
+ title: title,
+ description: 'please fix',
+ source_branch: 'feature',
+ target_branch: 'master'
+ }
+ end
+
+ subject { MergeRequests::CreateService.new(project, project.owner, params) }
+
+ describe '#execute_hooks' do
+ shared_examples 'enqueues Jira sync worker' do
+ it do
+ Sidekiq::Testing.fake! do
+ expect { subject.execute }.to change(JiraConnect::SyncMergeRequestWorker.jobs, :size).by(1)
+ end
+ end
+ end
+
+ 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)
+ end
+ end
+ end
+
+ context 'with a Jira subscription' do
+ before do
+ create(:jira_connect_subscription, namespace: project.namespace)
+ end
+
+ context 'MR contains Jira issue key' do
+ let(:title) { 'Awesome merge_request with issue JIRA-123' }
+
+ it_behaves_like 'enqueues Jira sync worker'
+ end
+
+ context 'MR does not contain Jira issue key' do
+ it_behaves_like 'does not enqueue Jira sync worker'
+ end
+ end
+
+ context 'without a Jira subscription' do
+ it_behaves_like 'does not enqueue Jira sync worker'
+ end
+ end
+end
diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb
index f99be26927d..f83b8d98ce8 100644
--- a/spec/services/merge_requests/build_service_spec.rb
+++ b/spec/services/merge_requests/build_service_spec.rb
@@ -88,6 +88,10 @@ RSpec.describe MergeRequests::BuildService do
let(:source_project) { fork_project(project, user) }
let(:merge_request) { described_class.new(project, user, mr_params).execute }
+ before do
+ project.add_reporter(user)
+ end
+
it 'assigns force_remove_source_branch' do
expect(merge_request.force_remove_source_branch?).to be_truthy
end
@@ -510,7 +514,7 @@ RSpec.describe MergeRequests::BuildService do
let(:target_project) { create(:project, :public, :repository) }
before do
- target_project.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ target_project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
end
it 'sets the target_project correctly' do
diff --git a/spec/services/merge_requests/cleanup_refs_service_spec.rb b/spec/services/merge_requests/cleanup_refs_service_spec.rb
new file mode 100644
index 00000000000..b38ccee4aa0
--- /dev/null
+++ b/spec/services/merge_requests/cleanup_refs_service_spec.rb
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::CleanupRefsService do
+ describe '.schedule' do
+ let(:merge_request) { build(:merge_request) }
+
+ it 'schedules MergeRequestCleanupRefsWorker' do
+ expect(MergeRequestCleanupRefsWorker)
+ .to receive(:perform_in)
+ .with(described_class::TIME_THRESHOLD, merge_request.id)
+
+ described_class.schedule(merge_request)
+ end
+ end
+
+ describe '#execute' do
+ before do
+ # Need to re-enable this as it's being stubbed in spec_helper for
+ # performance reasons but is needed to run for this test.
+ allow(Gitlab::Git::KeepAround).to receive(:execute).and_call_original
+ end
+
+ subject(:result) { described_class.new(merge_request).execute }
+
+ shared_examples_for 'service that cleans up merge request refs' do
+ it 'creates keep around ref and deletes merge request refs' do
+ old_ref_head = ref_head
+
+ aggregate_failures do
+ expect(result[:status]).to eq(:success)
+ expect(kept_around?(old_ref_head)).to be_truthy
+ expect(ref_head).to be_nil
+ end
+ end
+
+ context 'when merge request has merge ref' do
+ before do
+ MergeRequests::MergeToRefService
+ .new(merge_request.project, merge_request.author)
+ .execute(merge_request)
+ end
+
+ it 'caches merge ref sha and deletes merge ref' do
+ old_merge_ref_head = merge_request.merge_ref_head
+
+ aggregate_failures do
+ expect(result[:status]).to eq(:success)
+ expect(kept_around?(old_merge_ref_head)).to be_truthy
+ expect(merge_request.reload.merge_ref_sha).to eq(old_merge_ref_head.id)
+ expect(ref_exists?(merge_request.merge_ref_path)).to be_falsy
+ end
+ end
+
+ context 'when merge ref sha cannot be cached' do
+ before do
+ allow(merge_request)
+ .to receive(:update_column)
+ .with(:merge_ref_sha, merge_request.merge_ref_head.id)
+ .and_return(false)
+ end
+
+ it_behaves_like 'service that does not clean up merge request refs'
+ end
+ end
+
+ context 'when keep around ref cannot be created' do
+ before do
+ allow_next_instance_of(Gitlab::Git::KeepAround) do |keep_around|
+ expect(keep_around).to receive(:kept_around?).and_return(false)
+ end
+ end
+
+ it_behaves_like 'service that does not clean up merge request refs'
+ end
+ end
+
+ shared_examples_for 'service that does not clean up merge request refs' do
+ it 'does not delete merge request refs' do
+ aggregate_failures do
+ expect(result[:status]).to eq(:error)
+ expect(ref_head).to be_present
+ end
+ end
+ end
+
+ context 'when merge request is closed' do
+ let(:merge_request) { create(:merge_request, :closed) }
+
+ context "when closed #{described_class::TIME_THRESHOLD.inspect} ago" do
+ before do
+ merge_request.metrics.update!(latest_closed_at: described_class::TIME_THRESHOLD.ago)
+ end
+
+ it_behaves_like 'service that cleans up merge request refs'
+ end
+
+ context "when closed later than #{described_class::TIME_THRESHOLD.inspect} ago" do
+ before do
+ merge_request.metrics.update!(latest_closed_at: (described_class::TIME_THRESHOLD - 1.day).ago)
+ end
+
+ it_behaves_like 'service that does not clean up merge request refs'
+ end
+ end
+
+ context 'when merge request is merged' do
+ let(:merge_request) { create(:merge_request, :merged) }
+
+ context "when merged #{described_class::TIME_THRESHOLD.inspect} ago" do
+ before do
+ merge_request.metrics.update!(merged_at: described_class::TIME_THRESHOLD.ago)
+ end
+
+ it_behaves_like 'service that cleans up merge request refs'
+ end
+
+ context "when merged later than #{described_class::TIME_THRESHOLD.inspect} ago" do
+ before do
+ merge_request.metrics.update!(merged_at: (described_class::TIME_THRESHOLD - 1.day).ago)
+ end
+
+ it_behaves_like 'service that does not clean up merge request refs'
+ end
+ end
+
+ context 'when merge request is not closed nor merged' do
+ let(:merge_request) { create(:merge_request, :opened) }
+
+ it_behaves_like 'service that does not clean up merge request refs'
+ end
+ end
+
+ def kept_around?(commit)
+ Gitlab::Git::KeepAround.new(merge_request.project.repository).kept_around?(commit.id)
+ end
+
+ def ref_head
+ merge_request.project.repository.commit(merge_request.ref_path)
+ end
+
+ def ref_exists?(ref)
+ merge_request.project.repository.ref_exists?(ref)
+ end
+end
diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb
index e518e439a84..e7ac286f48b 100644
--- a/spec/services/merge_requests/close_service_spec.rb
+++ b/spec/services/merge_requests/close_service_spec.rb
@@ -99,6 +99,12 @@ RSpec.describe MergeRequests::CloseService do
described_class.new(project, user).execute(merge_request)
end
+ it 'schedules CleanupRefsService' do
+ expect(MergeRequests::CleanupRefsService).to receive(:schedule).with(merge_request)
+
+ described_class.new(project, user).execute(merge_request)
+ end
+
context 'current user is not authorized to close merge request' do
before do
perform_enqueued_jobs do
diff --git a/spec/services/merge_requests/conflicts/list_service_spec.rb b/spec/services/merge_requests/conflicts/list_service_spec.rb
index 14133731e37..5132eac0158 100644
--- a/spec/services/merge_requests/conflicts/list_service_spec.rb
+++ b/spec/services/merge_requests/conflicts/list_service_spec.rb
@@ -30,14 +30,13 @@ RSpec.describe MergeRequests::Conflicts::ListService do
it 'returns a falsey value when one of the MR branches is missing' do
merge_request = create_merge_request('conflict-resolvable')
merge_request.project.repository.rm_branch(merge_request.author, 'conflict-resolvable')
- merge_request.clear_memoized_source_branch_exists
expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the MR does not support new diff notes' do
merge_request = create_merge_request('conflict-resolvable')
- merge_request.merge_request_diff.update(start_commit_sha: nil)
+ merge_request.merge_request_diff.update!(start_commit_sha: nil)
expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey
end
diff --git a/spec/services/merge_requests/create_pipeline_service_spec.rb b/spec/services/merge_requests/create_pipeline_service_spec.rb
index db46bd37eea..4dd70627977 100644
--- a/spec/services/merge_requests/create_pipeline_service_spec.rb
+++ b/spec/services/merge_requests/create_pipeline_service_spec.rb
@@ -5,13 +5,14 @@ require 'spec_helper'
RSpec.describe MergeRequests::CreatePipelineService do
include ProjectForksHelper
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:project, reload: true) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let(:service) { described_class.new(project, actor, params) }
let(:actor) { user }
let(:params) { {} }
before do
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false)
project.add_developer(user)
end
@@ -58,9 +59,27 @@ RSpec.describe MergeRequests::CreatePipelineService do
expect(subject.project).to eq(project)
end
- context 'when ci_allow_to_create_merge_request_pipelines_in_target_project feature flag is disabled' do
+ context 'when source branch is protected' do
+ context 'when actor does not have permission to update the protected branch in target project' do
+ let!(:protected_branch) { create(:protected_branch, name: '*', project: project) }
+
+ it 'creates a pipeline in the source project' do
+ expect(subject.project).to eq(source_project)
+ end
+ end
+
+ context 'when actor has permission to update the protected branch in target project' do
+ let!(:protected_branch) { create(:protected_branch, :developers_can_merge, name: '*', project: project) }
+
+ it 'creates a pipeline in the target project' do
+ expect(subject.project).to eq(project)
+ end
+ end
+ end
+
+ context 'when ci_disallow_to_create_merge_request_pipelines_in_target_project feature flag is enabled' do
before do
- stub_feature_flags(ci_allow_to_create_merge_request_pipelines_in_target_project: false)
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: true)
end
it 'creates a pipeline in the source project' do
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index bb62e594e7a..d042b318d02 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
- let(:assignee) { create(:user) }
+ let(:user2) { create(:user) }
describe '#execute' do
context 'valid params' do
@@ -26,7 +26,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
before do
project.add_maintainer(user)
- project.add_developer(assignee)
+ project.add_developer(user2)
allow(service).to receive(:execute_hooks)
end
@@ -75,7 +75,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
description: "well this is not done yet\n/wip",
source_branch: 'feature',
target_branch: 'master',
- assignees: [assignee]
+ assignees: [user2]
}
end
@@ -91,7 +91,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
description: "well this is not done yet\n/wip",
source_branch: 'feature',
target_branch: 'master',
- assignees: [assignee]
+ assignees: [user2]
}
end
@@ -108,17 +108,17 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
description: 'please fix',
source_branch: 'feature',
target_branch: 'master',
- assignees: [assignee]
+ assignees: [user2]
}
end
- it { expect(merge_request.assignees).to eq([assignee]) }
+ it { expect(merge_request.assignees).to eq([user2]) }
it 'creates a todo for new assignee' do
attributes = {
project: project,
author: user,
- user: assignee,
+ user: user2,
target_id: merge_request.id,
target_type: merge_request.class.name,
action: Todo::ASSIGNED,
@@ -129,6 +129,34 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
end
end
+ context 'when reviewer is assigned' do
+ let(:opts) do
+ {
+ title: 'Awesome merge_request',
+ description: 'please fix',
+ source_branch: 'feature',
+ target_branch: 'master',
+ reviewers: [user2]
+ }
+ end
+
+ it { expect(merge_request.reviewers).to eq([user2]) }
+
+ it 'creates a todo for new reviewer' do
+ attributes = {
+ project: project,
+ author: user,
+ user: user2,
+ target_id: merge_request.id,
+ target_type: merge_request.class.name,
+ action: Todo::REVIEW_REQUESTED,
+ state: :pending
+ }
+
+ expect(Todo.where(attributes).count).to eq 1
+ end
+ end
+
context 'when head pipelines already exist for merge request source branch', :sidekiq_inline do
let(:shas) { project.repository.commits(opts[:source_branch], limit: 2).map(&:id) }
let!(:pipeline_1) { create(:ci_pipeline, project: project, ref: opts[:source_branch], project_id: project.id, sha: shas[1]) }
@@ -212,7 +240,8 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
end
before do
- target_project.add_developer(assignee)
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false)
+ target_project.add_developer(user2)
target_project.add_maintainer(user)
end
@@ -338,6 +367,10 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
end
end
end
+
+ it_behaves_like 'reviewer_ids filter' do
+ let(:execute) { service.execute }
+ end
end
it_behaves_like 'issuable record that supports quick actions' do
@@ -361,7 +394,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
assignee_ids: create(:user).id,
milestone_id: 1,
title: 'Title',
- description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}"),
+ description: %(/assign @#{user2.username}\n/milestone %"#{milestone.name}"),
source_branch: 'feature',
target_branch: 'master'
}
@@ -369,12 +402,12 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
before do
project.add_maintainer(user)
- project.add_maintainer(assignee)
+ project.add_maintainer(user2)
end
it 'assigns and sets milestone to issuable from command' do
expect(merge_request).to be_persisted
- expect(merge_request.assignees).to eq([assignee])
+ expect(merge_request.assignees).to eq([user2])
expect(merge_request.milestone).to eq(milestone)
end
end
@@ -382,7 +415,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
context 'merge request create service' do
context 'asssignee_id' do
- let(:assignee) { create(:user) }
+ let(:user2) { create(:user) }
before do
project.add_maintainer(user)
@@ -405,12 +438,12 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
end
it 'saves assignee when user id is valid' do
- project.add_maintainer(assignee)
- opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] }
+ project.add_maintainer(user2)
+ opts = { title: 'Title', description: 'Description', assignee_ids: [user2.id] }
merge_request = described_class.new(project, user, opts).execute
- expect(merge_request.assignees).to eq([assignee])
+ expect(merge_request.assignees).to eq([user2])
end
context 'when assignee is set' do
@@ -418,24 +451,24 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
{
title: 'Title',
description: 'Description',
- assignee_ids: [assignee.id],
+ assignee_ids: [user2.id],
source_branch: 'feature',
target_branch: 'master'
}
end
it 'invalidates open merge request counter for assignees when merge request is assigned' do
- project.add_maintainer(assignee)
+ project.add_maintainer(user2)
described_class.new(project, user, opts).execute
- expect(assignee.assigned_open_merge_requests_count).to eq 1
+ expect(user2.assigned_open_merge_requests_count).to eq 1
end
end
context "when issuable feature is private" do
before do
- project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE,
+ project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE,
merge_requests_access_level: ProjectFeature::PRIVATE)
end
@@ -443,8 +476,8 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
levels.each do |level|
it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
- project.update(visibility_level: level)
- opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] }
+ project.update!(visibility_level: level)
+ opts = { title: 'Title', description: 'Description', assignee_ids: [user2.id] }
merge_request = described_class.new(project, user, opts).execute
@@ -470,7 +503,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
before do
project.add_maintainer(user)
- project.add_developer(assignee)
+ project.add_developer(user2)
end
it 'creates a `MergeRequestsClosingIssues` record for each issue' do
@@ -498,7 +531,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
context 'when user can not access source project' do
before do
- target_project.add_developer(assignee)
+ target_project.add_developer(user2)
target_project.add_maintainer(user)
end
@@ -510,7 +543,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
context 'when user can not access target project' do
before do
- target_project.add_developer(assignee)
+ target_project.add_developer(user2)
target_project.add_maintainer(user)
end
@@ -562,7 +595,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
end
before do
- project.add_developer(assignee)
+ project.add_developer(user2)
project.add_maintainer(user)
end
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 377615bbc6f..cdaacaf5fca 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
@@ -19,7 +19,7 @@ RSpec.describe MergeRequests::DeleteNonLatestDiffsService, :clean_gitlab_redis_s
expect(diffs.count).to eq(4)
- Timecop.freeze do
+ freeze_time do
expect(DeleteDiffFilesWorker)
.to receive(:bulk_perform_in)
.with(5.minutes, [[diffs.first.id], [diffs.second.id]])
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index 11e341994f7..8328f461029 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -152,6 +152,7 @@ RSpec.describe MergeRequests::MergeService do
let(:commit) { double('commit', safe_message: "Fixes #{jira_issue.to_reference}") }
before do
+ stub_jira_service_test
project.update!(has_external_issue_tracker: true)
jira_service_settings
stub_jira_urls(jira_issue.id)
@@ -175,7 +176,7 @@ RSpec.describe MergeRequests::MergeService do
end
it 'does not close issue' do
- jira_tracker.update(jira_issue_transition_id: nil)
+ jira_tracker.update!(jira_issue_transition_id: nil)
expect_any_instance_of(JiraService).not_to receive(:transition_issue)
@@ -388,7 +389,7 @@ RSpec.describe MergeRequests::MergeService do
error_message = 'Failed to squash. Should be done manually'
allow_any_instance_of(MergeRequests::SquashService).to receive(:squash!).and_return(nil)
- merge_request.update(squash: true)
+ merge_request.update!(squash: true)
service.execute(merge_request)
@@ -402,7 +403,7 @@ RSpec.describe MergeRequests::MergeService do
error_message = 'another squash is already in progress'
allow_any_instance_of(MergeRequest).to receive(:squash_in_progress?).and_return(true)
- merge_request.update(squash: true)
+ merge_request.update!(squash: true)
service.execute(merge_request)
@@ -420,7 +421,7 @@ RSpec.describe MergeRequests::MergeService do
%w(semi-linear ff).each do |merge_method|
it "logs and saves error if merge is #{merge_method} only" do
merge_method = 'rebase_merge' if merge_method == 'semi-linear'
- merge_request.project.update(merge_method: merge_method)
+ merge_request.project.update!(merge_method: merge_method)
error_message = 'Only fast-forward merge is allowed for your project. Please update your source branch'
allow(service).to receive(:execute_hooks)
@@ -434,6 +435,43 @@ RSpec.describe MergeRequests::MergeService do
end
end
end
+
+ context 'when not mergeable' do
+ let!(:error_message) { 'Merge request is not mergeable' }
+
+ context 'with failing CI' do
+ before do
+ allow(merge_request).to receive(:mergeable_ci_state?) { false }
+ end
+
+ it 'logs and saves error' do
+ service.execute(merge_request)
+
+ expect(Gitlab::AppLogger).to have_received(:error).with(a_string_matching(error_message))
+ end
+ end
+
+ context 'with unresolved discussions' do
+ before do
+ allow(merge_request).to receive(:mergeable_discussions_state?) { false }
+ end
+
+ it 'logs and saves error' do
+ service.execute(merge_request)
+
+ expect(Gitlab::AppLogger).to have_received(:error).with(a_string_matching(error_message))
+ end
+
+ context 'when passing `skip_discussions_check: true` as `options` parameter' do
+ it 'merges the merge request' do
+ service.execute(merge_request, skip_discussions_check: true)
+
+ expect(merge_request).to be_valid
+ expect(merge_request).to be_merged
+ end
+ end
+ end
+ end
end
end
end
diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb
index a51a896ca96..402f753c0af 100644
--- a/spec/services/merge_requests/post_merge_service_spec.rb
+++ b/spec/services/merge_requests/post_merge_service_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe MergeRequests::PostMergeService do
end
it 'marks MR as merged regardless of errors when closing issues' do
- merge_request.update(target_branch: 'foo')
+ merge_request.update!(target_branch: 'foo')
allow(project).to receive(:default_branch).and_return('foo')
issue = create(:issue, project: project)
@@ -72,6 +72,12 @@ RSpec.describe MergeRequests::PostMergeService do
subject
end
+ it 'schedules CleanupRefsService' do
+ expect(MergeRequests::CleanupRefsService).to receive(:schedule).with(merge_request)
+
+ subject
+ end
+
context 'when the merge request has review apps' do
it 'cancels all review app deployments' do
pipeline = create(:ci_pipeline,
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 0696e8a247f..cace1e0bf09 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -225,6 +225,10 @@ RSpec.describe MergeRequests::RefreshService do
context 'when service runs on forked project' do
let(:project) { @fork_project }
+ before do
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false)
+ end
+
it 'creates detached merge request pipeline for fork merge request', :sidekiq_inline do
expect { subject }
.to change { @fork_merge_request.pipelines_for_merge_request.count }.by(1)
@@ -617,7 +621,7 @@ RSpec.describe MergeRequests::RefreshService do
before do
stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
- @fork_project.destroy
+ @fork_project.destroy!
service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
reload_mrs
end
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index c3433c8c9d2..6b7463d4996 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -52,6 +52,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
title: 'New title',
description: 'Also please fix',
assignee_ids: [user.id],
+ reviewer_ids: [user.id],
state_event: 'close',
label_ids: [label.id],
target_branch: 'target',
@@ -75,6 +76,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
expect(@merge_request).to be_valid
expect(@merge_request.title).to eq('New title')
expect(@merge_request.assignees).to match_array([user])
+ expect(@merge_request.reviewers).to match_array([user])
expect(@merge_request).to be_closed
expect(@merge_request.labels.count).to eq(1)
expect(@merge_request.labels.first.title).to eq(label.name)
@@ -161,6 +163,29 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
expect(@merge_request.merge_params["force_remove_source_branch"]).to eq("1")
end
end
+
+ it_behaves_like 'reviewer_ids filter' do
+ let(:opts) { {} }
+ let(:execute) { update_merge_request(opts) }
+ end
+
+ context 'with an existing reviewer' do
+ let(:merge_request) do
+ create(:merge_request, :simple, source_project: project, reviewer_ids: [user2.id])
+ end
+
+ context 'when merge_request_reviewer feature is enabled' do
+ before do
+ stub_feature_flags(merge_request_reviewer: true)
+ end
+
+ let(:opts) { { reviewer_ids: [IssuableFinder::Params::NONE] } }
+
+ it 'removes reviewers' do
+ expect(update_merge_request(opts).reviewers).to eq []
+ end
+ end
+ end
end
context 'after_save callback to store_mentions' do
@@ -379,11 +404,31 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
end
- context 'when the milestone is removed' do
+ context 'when reviewers gets changed' do
before do
- stub_feature_flags(track_resource_milestone_change_events: false)
+ update_merge_request({ reviewer_ids: [user2.id] })
+ end
+
+ it 'marks pending todo as done' do
+ expect(pending_todo.reload).to be_done
+ end
+
+ it 'creates a pending todo for new review request' do
+ attributes = {
+ project: project,
+ author: user,
+ user: user2,
+ target_id: merge_request.id,
+ target_type: merge_request.class.name,
+ action: Todo::REVIEW_REQUESTED,
+ state: :pending
+ }
+
+ expect(Todo.where(attributes).count).to eq 1
end
+ end
+ context 'when the milestone is removed' do
let!(:non_subscriber) { create(:user) }
let!(:subscriber) do
@@ -393,12 +438,10 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
end
- it_behaves_like 'system notes for milestones'
-
it 'sends notifications for subscribers of changed milestone', :sidekiq_might_not_need_inline do
merge_request.milestone = create(:milestone, project: project)
- merge_request.save
+ merge_request.save!
perform_enqueued_jobs do
update_merge_request(milestone_id: "")
@@ -410,10 +453,6 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
context 'when the milestone is changed' do
- before do
- stub_feature_flags(track_resource_milestone_change_events: false)
- end
-
let!(:non_subscriber) { create(:user) }
let!(:subscriber) do
@@ -429,8 +468,6 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
expect(pending_todo.reload).to be_done
end
- it_behaves_like 'system notes for milestones'
-
it 'sends notifications for subscribers of changed milestone', :sidekiq_might_not_need_inline do
perform_enqueued_jobs do
update_merge_request(milestone: create(:milestone, project: project))
@@ -628,7 +665,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
context 'updating asssignee_ids' do
it 'does not update assignee when assignee_id is invalid' do
- merge_request.update(assignee_ids: [user.id])
+ merge_request.update!(assignee_ids: [user.id])
update_merge_request(assignee_ids: [-1])
@@ -636,7 +673,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
it 'unassigns assignee when user id is 0' do
- merge_request.update(assignee_ids: [user.id])
+ merge_request.update!(assignee_ids: [user.id])
update_merge_request(assignee_ids: [0])
@@ -664,7 +701,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
levels.each do |level|
it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
assignee = create(:user)
- project.update(visibility_level: level)
+ project.update!(visibility_level: level)
feature_visibility_attr = :"#{merge_request.model_name.plural}_access_level"
project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE)
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 dd9d498e307..d5928b1b5af 100644
--- a/spec/services/metrics/dashboard/gitlab_alert_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/gitlab_alert_embed_service_spec.rb
@@ -10,9 +10,8 @@ RSpec.describe Metrics::Dashboard::GitlabAlertEmbedService do
let_it_be(:user) { create(:user) }
let(:alert_id) { alert.id }
- before do
+ before_all do
project.add_maintainer(user)
- project.clear_memoization(:licensed_feature_available)
end
describe '.valid_params?' do
diff --git a/spec/services/note_summary_spec.rb b/spec/services/note_summary_spec.rb
index 38174748b19..ad244f62292 100644
--- a/spec/services/note_summary_spec.rb
+++ b/spec/services/note_summary_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe NoteSummary do
describe '#note' do
it 'returns note hash' do
- Timecop.freeze do
+ freeze_time do
expect(create_note_summary.note).to eq(noteable: noteable, project: project, author: user, note: 'note',
created_at: Time.current)
end
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index f087f72ca46..7c0d4b756bd 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -41,7 +41,7 @@ RSpec.describe Notes::CreateService do
end
it 'TodoService#new_note is called' do
- note = build(:note, project: project)
+ note = build(:note, project: project, noteable: issue)
allow(Note).to receive(:new).with(opts) { note }
expect_any_instance_of(TodoService).to receive(:new_note).with(note, user)
@@ -50,13 +50,23 @@ RSpec.describe Notes::CreateService do
end
it 'enqueues NewNoteWorker' do
- note = build(:note, id: non_existing_record_id, project: project)
+ note = build(:note, id: non_existing_record_id, project: project, noteable: issue)
allow(Note).to receive(:new).with(opts) { note }
expect(NewNoteWorker).to receive(:perform_async).with(note.id)
described_class.new(project, user, opts).execute
end
+
+ context 'issue is an incident' do
+ subject { described_class.new(project, user, opts).execute }
+
+ let(:issue) { create(:incident, project: project) }
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_comment do
+ let(:current_user) { user }
+ end
+ end
end
context 'noteable highlight cache clearing' do
diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb
index e9decd44730..64aa845841b 100644
--- a/spec/services/notes/quick_actions_service_spec.rb
+++ b/spec/services/notes/quick_actions_service_spec.rb
@@ -4,9 +4,9 @@ require 'spec_helper'
RSpec.describe Notes::QuickActionsService do
shared_context 'note on noteable' do
- let(:project) { create(:project, :repository) }
- let(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } }
- let(:assignee) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } }
+ let_it_be(:assignee) { create(:user) }
before do
project.add_maintainer(assignee)
@@ -30,10 +30,9 @@ RSpec.describe Notes::QuickActionsService do
end
it 'closes noteable, sets labels, assigns, and sets milestone to noteable, and leave no note' do
- content, update_params = service.execute(note)
- service.apply_updates(update_params, note)
+ content = execute(note)
- expect(content).to eq ''
+ 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])
@@ -41,6 +40,30 @@ RSpec.describe Notes::QuickActionsService do
end
end
+ context '/relate' do
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:other_issue) { create(:issue, project: project) }
+ let(:note_text) { "/relate #{other_issue.to_reference}" }
+ let(:note) { create(:note_on_issue, noteable: issue, project: project, note: note_text) }
+
+ context 'user cannot relate issues' do
+ before do
+ project.team.find_member(maintainer.id).destroy!
+ project.update!(visibility: Gitlab::VisibilityLevel::PUBLIC)
+ end
+
+ it 'does not create issue relation' do
+ expect { execute(note) }.not_to change { IssueLink.count }
+ end
+ end
+
+ context 'user is allowed to relate issues' do
+ it 'creates issue relation' do
+ expect { execute(note) }.to change { IssueLink.count }.by(1)
+ end
+ end
+ end
+
describe '/reopen' do
before do
note.noteable.close!
@@ -49,10 +72,9 @@ RSpec.describe Notes::QuickActionsService do
let(:note_text) { '/reopen' }
it 'opens the noteable, and leave no note' do
- content, update_params = service.execute(note)
- service.apply_updates(update_params, note)
+ content = execute(note)
- expect(content).to eq ''
+ expect(content).to be_empty
expect(note.noteable).to be_open
end
end
@@ -62,10 +84,9 @@ RSpec.describe Notes::QuickActionsService do
let(:note_text) { '/spend 1h' }
it 'adds time to noteable, adds timelog with nil note_id and has no content' do
- content, update_params = service.execute(note)
- service.apply_updates(update_params, note)
+ content = execute(note)
- expect(content).to eq ''
+ expect(content).to be_empty
expect(note.noteable.time_spent).to eq(3600)
expect(Timelog.last.note_id).to be_nil
end
@@ -92,8 +113,7 @@ RSpec.describe Notes::QuickActionsService do
end
it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do
- content, update_params = service.execute(note)
- service.apply_updates(update_params, note)
+ content = execute(note)
expect(content).to eq "HELLO\nWORLD"
expect(note.noteable).to be_closed
@@ -111,14 +131,87 @@ RSpec.describe Notes::QuickActionsService do
let(:note_text) { "HELLO\n/reopen\nWORLD" }
it 'opens the noteable' do
- content, update_params = service.execute(note)
- service.apply_updates(update_params, note)
+ content = execute(note)
expect(content).to eq "HELLO\nWORLD"
expect(note.noteable).to be_open
end
end
end
+
+ describe '/milestone' do
+ let(:issue) { create(:issue, project: project) }
+ let(:note_text) { %(/milestone %"#{milestone.name}") }
+ let(:note) { create(:note_on_issue, noteable: issue, project: project, note: note_text) }
+
+ context 'on an incident' do
+ before do
+ issue.update!(issue_type: :incident)
+ end
+
+ it 'leaves the note empty' do
+ expect(execute(note)).to be_empty
+ end
+
+ it 'does not assign the milestone' do
+ expect { execute(note) }.not_to change { issue.reload.milestone }
+ end
+ end
+
+ context 'on a merge request' do
+ let(:note_mr) { create(:note_on_merge_request, project: project, note: note_text) }
+
+ it 'leaves the note empty' do
+ expect(execute(note_mr)).to be_empty
+ end
+
+ it 'assigns the milestone' do
+ expect { execute(note) }.to change { issue.reload.milestone }.from(nil).to(milestone)
+ end
+ end
+ end
+
+ describe '/remove_milestone' do
+ let(:issue) { create(:issue, project: project, milestone: milestone) }
+ let(:note_text) { '/remove_milestone' }
+ let(:note) { create(:note_on_issue, noteable: issue, project: project, note: note_text) }
+
+ context 'on an issue' do
+ it 'leaves the note empty' do
+ expect(execute(note)).to be_empty
+ end
+
+ it 'removes the milestone' do
+ expect { execute(note) }.to change { issue.reload.milestone }.from(milestone).to(nil)
+ end
+ end
+
+ context 'on an incident' do
+ before do
+ issue.update!(issue_type: :incident)
+ end
+
+ it 'leaves the note empty' do
+ expect(execute(note)).to be_empty
+ end
+
+ it 'does not remove the milestone' do
+ expect { execute(note) }.not_to change { issue.reload.milestone }
+ end
+ end
+
+ context 'on a merge request' do
+ let(:note_mr) { create(:note_on_merge_request, project: project, note: note_text) }
+
+ it 'leaves the note empty' do
+ expect(execute(note_mr)).to be_empty
+ end
+
+ it 'removes the milestone' do
+ expect { execute(note) }.to change { issue.reload.milestone }.from(milestone).to(nil)
+ end
+ end
+ end
end
describe '.noteable_update_service' do
@@ -180,11 +273,13 @@ RSpec.describe Notes::QuickActionsService do
let(:service) { described_class.new(project, maintainer) }
it_behaves_like 'note on noteable that supports quick actions' do
- let(:note) { build(:note_on_issue, project: project) }
+ let_it_be(:issue, reload: true) { create(:issue, project: project) }
+ let(:note) { build(:note_on_issue, project: project, noteable: issue) }
end
it_behaves_like 'note on noteable that supports quick actions' do
- let(:note) { build(:note_on_merge_request, project: project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:note) { build(:note_on_merge_request, project: project, noteable: merge_request) }
end
end
@@ -207,11 +302,17 @@ RSpec.describe Notes::QuickActionsService do
end
it 'adds only one assignee from the list' do
- _, update_params = service.execute(note)
- service.apply_updates(update_params, note)
+ execute(note)
expect(note.noteable.assignees.count).to eq(1)
end
end
end
+
+ def execute(note)
+ content, update_params = service.execute(note)
+ service.apply_updates(update_params, note)
+
+ content
+ end
end
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 8186bc40bc0..03e24524f9f 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -7,8 +7,10 @@ RSpec.describe NotificationService, :mailer do
include ExternalAuthorizationServiceHelpers
include NotificationHelpers
+ let_it_be(:project, reload: true) { create(:project, :public) }
+ let_it_be_with_refind(:assignee) { create(:user) }
+
let(:notification) { described_class.new }
- let(:assignee) { create(:user) }
around(:example, :deliver_mails_inline) do |example|
# This is a temporary `around` hook until all the examples check the
@@ -149,9 +151,9 @@ RSpec.describe NotificationService, :mailer do
end
shared_examples_for 'participating notifications' do
- it_should_behave_like 'participating by note notification'
- it_should_behave_like 'participating by author notification'
- it_should_behave_like 'participating by assignee notification'
+ it_behaves_like 'participating by note notification'
+ it_behaves_like 'participating by author notification'
+ it_behaves_like 'participating by assignee notification'
end
describe '#async' do
@@ -272,97 +274,26 @@ RSpec.describe NotificationService, :mailer do
end
end
+ describe '#disabled_two_factor' do
+ let_it_be(:user) { create(:user) }
+
+ subject { notification.disabled_two_factor(user) }
+
+ it 'sends email to the user' do
+ expect { subject }.to have_enqueued_email(user, mail: 'disabled_two_factor_email')
+ end
+ end
+
describe 'Notes' do
context 'issue note' do
- let(:project) { create(:project, :private) }
- let(:issue) { create(:issue, project: project, assignees: [assignee]) }
- let(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
- let(:author) { create(:user) }
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:issue) { create(:issue, project: project, assignees: [assignee]) }
+ let_it_be(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
+ let_it_be_with_reload(:author) { create(:user) }
let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') }
subject { notification.new_note(note) }
- before do
- build_team(project)
- project.add_maintainer(issue.author)
- project.add_maintainer(assignee)
- project.add_maintainer(author)
-
- @u_custom_off = create_user_with_notification(:custom, 'custom_off')
- project.add_guest(@u_custom_off)
-
- create(
- :note_on_issue,
- author: @u_custom_off,
- noteable: issue,
- project_id: issue.project_id,
- note: 'i think @subscribed_participant should see this'
- )
-
- update_custom_notification(:new_note, @u_guest_custom, resource: project)
- update_custom_notification(:new_note, @u_custom_global)
- end
-
- describe '#new_note' do
- context do
- before do
- add_users(project)
- add_user_subscriptions(issue)
- reset_delivered_emails!
- end
-
- it 'sends emails to recipients' do
- subject
-
- expect_delivery_jobs_count(10)
- expect_enqueud_email(@u_watcher.id, note.id, nil, mail: "note_issue_email")
- expect_enqueud_email(note.noteable.author.id, note.id, nil, mail: "note_issue_email")
- expect_enqueud_email(note.noteable.assignees.first.id, note.id, nil, mail: "note_issue_email")
- expect_enqueud_email(@u_custom_global.id, note.id, nil, mail: "note_issue_email")
- expect_enqueud_email(@u_mentioned.id, note.id, "mentioned", mail: "note_issue_email")
- expect_enqueud_email(@subscriber.id, note.id, "subscribed", mail: "note_issue_email")
- expect_enqueud_email(@watcher_and_subscriber.id, note.id, "subscribed", mail: "note_issue_email")
- expect_enqueud_email(@subscribed_participant.id, note.id, "subscribed", mail: "note_issue_email")
- expect_enqueud_email(@u_custom_off.id, note.id, nil, mail: "note_issue_email")
- expect_enqueud_email(@unsubscribed_mentioned.id, note.id, "mentioned", mail: "note_issue_email")
- end
-
- it "emails the note author if they've opted into notifications about their activity", :deliver_mails_inline do
- note.author.notified_of_own_activity = true
-
- notification.new_note(note)
-
- should_email(note.author)
- expect(find_email_for(note.author)).to have_header('X-GitLab-NotificationReason', 'own_activity')
- end
-
- it_behaves_like 'project emails are disabled', check_delivery_jobs_queue: true do
- let(:notification_target) { note }
- let(:notification_trigger) { notification.new_note(note) }
- end
- end
-
- it 'filters out "mentioned in" notes' do
- mentioned_note = SystemNoteService.cross_reference(mentioned_issue, issue, issue.author)
- reset_delivered_emails!
-
- notification.new_note(mentioned_note)
-
- expect_no_delivery_jobs
- end
-
- context 'participating' do
- context 'by note' do
- before do
- note.author = @u_lazy_participant
- note.save
- end
-
- it { expect { subject }.not_to have_enqueued_email(@u_lazy_participant.id, note.id, mail: "note_issue_email") }
- end
- end
- end
-
context 'on service desk issue' do
before do
allow(Notify).to receive(:service_desk_new_note_email)
@@ -436,73 +367,158 @@ RSpec.describe NotificationService, :mailer do
end
end
- describe 'new note on issue in project that belongs to a group' do
- before do
- note.project.namespace_id = group.id
- group.add_user(@u_watcher, GroupMember::MAINTAINER)
- group.add_user(@u_custom_global, GroupMember::MAINTAINER)
- note.project.save
+ describe '#new_note' do
+ before_all do
+ build_team(project)
+ project.add_maintainer(issue.author)
+ project.add_maintainer(assignee)
+ project.add_maintainer(author)
+
+ @u_custom_off = create_user_with_notification(:custom, 'custom_off')
+ project.add_guest(@u_custom_off)
+
+ create(
+ :note_on_issue,
+ author: @u_custom_off,
+ noteable: issue,
+ project_id: issue.project_id,
+ note: 'i think @subscribed_participant should see this'
+ )
- @u_watcher.notification_settings_for(note.project).participating!
- @u_watcher.notification_settings_for(group).global!
+ update_custom_notification(:new_note, @u_guest_custom, resource: project)
update_custom_notification(:new_note, @u_custom_global)
- reset_delivered_emails!
end
- shared_examples 'new note notifications' do
- it 'sends notifications', :deliver_mails_inline do
+ context 'with users' do
+ before_all do
+ add_users(project)
+ add_user_subscriptions(issue)
+ end
+
+ before do
+ reset_delivered_emails!
+ end
+
+ it 'sends emails to recipients', :aggregate_failures do
+ subject
+
+ expect_delivery_jobs_count(10)
+ expect_enqueud_email(@u_watcher.id, note.id, nil, mail: "note_issue_email")
+ expect_enqueud_email(note.noteable.author.id, note.id, nil, mail: "note_issue_email")
+ expect_enqueud_email(note.noteable.assignees.first.id, note.id, nil, mail: "note_issue_email")
+ expect_enqueud_email(@u_custom_global.id, note.id, nil, mail: "note_issue_email")
+ expect_enqueud_email(@u_mentioned.id, note.id, "mentioned", mail: "note_issue_email")
+ expect_enqueud_email(@subscriber.id, note.id, "subscribed", mail: "note_issue_email")
+ expect_enqueud_email(@watcher_and_subscriber.id, note.id, "subscribed", mail: "note_issue_email")
+ expect_enqueud_email(@subscribed_participant.id, note.id, "subscribed", mail: "note_issue_email")
+ expect_enqueud_email(@u_custom_off.id, note.id, nil, mail: "note_issue_email")
+ expect_enqueud_email(@unsubscribed_mentioned.id, note.id, "mentioned", mail: "note_issue_email")
+ end
+
+ it "emails the note author if they've opted into notifications about their activity", :deliver_mails_inline do
+ note.author.notified_of_own_activity = true
+
notification.new_note(note)
- should_email(note.noteable.author)
- should_email(note.noteable.assignees.first)
- should_email(@u_mentioned)
- should_email(@u_custom_global)
- should_not_email(@u_guest_custom)
- should_not_email(@u_guest_watcher)
- should_not_email(@u_watcher)
- should_not_email(note.author)
- should_not_email(@u_participating)
- should_not_email(@u_disabled)
- should_not_email(@u_lazy_participant)
+ should_email(note.author)
+ expect(find_email_for(note.author)).to have_header('X-GitLab-NotificationReason', 'own_activity')
+ end
- expect(find_email_for(@u_mentioned)).to have_header('X-GitLab-NotificationReason', 'mentioned')
- expect(find_email_for(@u_custom_global)).to have_header('X-GitLab-NotificationReason', '')
+ it_behaves_like 'project emails are disabled', check_delivery_jobs_queue: true do
+ let(:notification_target) { note }
+ let(:notification_trigger) { notification.new_note(note) }
end
end
- let(:group) { create(:group) }
+ it 'filters out "mentioned in" notes' do
+ mentioned_note = SystemNoteService.cross_reference(mentioned_issue, issue, issue.author)
+ reset_delivered_emails!
- it_behaves_like 'new note notifications'
+ notification.new_note(mentioned_note)
- it_behaves_like 'project emails are disabled', check_delivery_jobs_queue: true do
- let(:notification_target) { note }
- let(:notification_trigger) { notification.new_note(note) }
+ expect_no_delivery_jobs
end
- context 'which is a subgroup' do
- let!(:parent) { create(:group) }
- let!(:group) { create(:group, parent: parent) }
+ context 'participating' do
+ context 'by note' do
+ before do
+ note.author = @u_lazy_participant
+ note.save
+ end
- it_behaves_like 'new note notifications'
+ it { expect { subject }.not_to have_enqueued_email(@u_lazy_participant.id, note.id, mail: "note_issue_email") }
+ end
+ end
+
+ context 'in project that belongs to a group' do
+ let_it_be(:parent_group) { create(:group) }
- it 'overrides child objects with global level' do
- user = create(:user)
- parent.add_developer(user)
- user.notification_settings_for(parent).watch!
+ before do
+ note.project.namespace_id = group.id
+ group.add_user(@u_watcher, GroupMember::MAINTAINER)
+ group.add_user(@u_custom_global, GroupMember::MAINTAINER)
+ note.project.save
+
+ @u_watcher.notification_settings_for(note.project).participating!
+ @u_watcher.notification_settings_for(group).global!
+ update_custom_notification(:new_note, @u_custom_global)
reset_delivered_emails!
+ end
- notification.new_note(note)
+ shared_examples 'new note notifications' do
+ it 'sends notifications', :deliver_mails_inline do
+ notification.new_note(note)
+
+ should_email(note.noteable.author)
+ should_email(note.noteable.assignees.first)
+ should_email(@u_mentioned)
+ should_email(@u_custom_global)
+ should_not_email(@u_guest_custom)
+ should_not_email(@u_guest_watcher)
+ should_not_email(@u_watcher)
+ should_not_email(note.author)
+ should_not_email(@u_participating)
+ should_not_email(@u_disabled)
+ should_not_email(@u_lazy_participant)
+
+ expect(find_email_for(@u_mentioned)).to have_header('X-GitLab-NotificationReason', 'mentioned')
+ expect(find_email_for(@u_custom_global)).to have_header('X-GitLab-NotificationReason', '')
+ end
+ end
+
+ context 'which is a top-level group' do
+ let!(:group) { parent_group }
- expect_enqueud_email(user.id, note.id, nil, mail: "note_issue_email")
+ it_behaves_like 'new note notifications'
+
+ it_behaves_like 'project emails are disabled', check_delivery_jobs_queue: true do
+ let(:notification_target) { note }
+ let(:notification_trigger) { notification.new_note(note) }
+ end
+ end
+
+ context 'which is a subgroup' do
+ let!(:group) { create(:group, parent: parent_group) }
+
+ it_behaves_like 'new note notifications'
+
+ it 'overrides child objects with global level' do
+ user = create(:user)
+ parent_group.add_developer(user)
+ user.notification_settings_for(parent_group).watch!
+ reset_delivered_emails!
+
+ notification.new_note(note)
+
+ expect_enqueud_email(user.id, note.id, nil, mail: "note_issue_email")
+ end
end
end
end
end
context 'confidential issue note' do
- let(:project) { create(:project, :public) }
let(:author) { create(:user) }
- let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:guest) { create(:user) }
@@ -556,18 +572,20 @@ RSpec.describe NotificationService, :mailer do
end
context 'issue note mention', :deliver_mails_inline do
- let(:project) { create(:project, :public) }
- let(:issue) { create(:issue, project: project, assignees: [assignee]) }
- let(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
- let(:author) { create(:user) }
+ let_it_be(:issue) { create(:issue, project: project, assignees: [assignee]) }
+ let_it_be(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
+ let_it_be(:author) { create(:user) }
let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@all mentioned') }
- before do
+ before_all do
build_team(project)
build_group(project)
add_users(project)
add_user_subscriptions(issue)
project.add_maintainer(author)
+ end
+
+ before do
reset_delivered_emails!
end
@@ -622,7 +640,6 @@ RSpec.describe NotificationService, :mailer do
end
context 'project snippet note', :deliver_mails_inline do
- let!(:project) { create(:project, :public) }
let(:snippet) { create(:project_snippet, project: project, author: create(:user)) }
let(:author) { create(:user) }
let(:note) { create(:note_on_project_snippet, author: author, noteable: snippet, project_id: project.id, note: '@all mentioned') }
@@ -715,18 +732,21 @@ RSpec.describe NotificationService, :mailer do
end
context 'commit note', :deliver_mails_inline do
- let(:project) { create(:project, :public, :repository) }
- let(:note) { create(:note_on_commit, project: project) }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:note) { create(:note_on_commit, project: project) }
- before do
- build_team(note.project)
+ before_all do
+ build_team(project)
build_group(project)
- reset_delivered_emails!
- allow(note.noteable).to receive(:author).and_return(@u_committer)
update_custom_notification(:new_note, @u_guest_custom, resource: project)
update_custom_notification(:new_note, @u_custom_global)
end
+ before do
+ reset_delivered_emails!
+ allow(note.noteable).to receive(:author).and_return(@u_committer)
+ end
+
describe '#new_note, #perform_enqueued_jobs' do
it do
notification.new_note(note)
@@ -774,12 +794,12 @@ RSpec.describe NotificationService, :mailer do
end
context "merge request diff note", :deliver_mails_inline do
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request, source_project: project, assignees: [user], author: create(:user)) }
- let(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [user], author: create(:user)) }
+ let_it_be(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
- before do
+ before_all do
build_team(note.project)
project.add_maintainer(merge_request.author)
merge_request.assignees.each { |assignee| project.add_maintainer(assignee) }
@@ -878,11 +898,11 @@ RSpec.describe NotificationService, :mailer do
end
describe 'Participating project notification settings have priority over group and global settings if available', :deliver_mails_inline do
- let!(:group) { create(:group) }
- let!(:maintainer) { group.add_owner(create(:user, username: 'maintainer')).user }
- let!(:user1) { group.add_developer(create(:user, username: 'user_with_project_and_custom_setting')).user }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:maintainer) { group.add_owner(create(:user, username: 'maintainer')).user }
+ let_it_be(:user1) { group.add_developer(create(:user, username: 'user_with_project_and_custom_setting')).user }
+ let_it_be(:project) { create(:project, :public, namespace: group) }
- let(:project) { create(:project, :public, namespace: group) }
let(:issue) { create :issue, project: project, assignees: [assignee], description: '' }
before do
@@ -936,20 +956,25 @@ RSpec.describe NotificationService, :mailer do
end
describe 'Issues', :deliver_mails_inline do
- let(:group) { create(:group) }
- let(:project) { create(:project, :public, namespace: group) }
let(:another_project) { create(:project, :public, namespace: group) }
let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant @unsubscribed_mentioned' }
- before do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :public, namespace: group) }
+
+ before_all do
build_team(project)
build_group(project)
-
add_users(project)
+ end
+
+ before do
add_user_subscriptions(issue)
reset_delivered_emails!
update_custom_notification(:new_issue, @u_guest_custom, resource: project)
update_custom_notification(:new_issue, @u_custom_global)
+
+ issue.author.notified_of_own_activity = false
end
describe '#new_issue' do
@@ -1066,7 +1091,6 @@ RSpec.describe NotificationService, :mailer do
context 'confidential issues' do
let(:author) { create(:user) }
- let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:guest) { create(:user) }
@@ -1272,7 +1296,6 @@ RSpec.describe NotificationService, :mailer do
context 'confidential issues' do
let(:author) { create(:user) }
- let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:guest) { create(:user) }
@@ -1325,7 +1348,6 @@ RSpec.describe NotificationService, :mailer do
context 'confidential issues' do
let(:author) { create(:user) }
- let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:guest) { create(:user) }
@@ -1377,7 +1399,6 @@ RSpec.describe NotificationService, :mailer do
context 'confidential issues' do
let(:author) { create(:user) }
- let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:guest) { create(:user) }
@@ -1571,19 +1592,23 @@ RSpec.describe NotificationService, :mailer do
end
describe 'Merge Requests', :deliver_mails_inline do
- let(:group) { create(:group) }
- let(:project) { create(:project, :public, :repository, namespace: group) }
let(:another_project) { create(:project, :public, namespace: group) }
- let(:assignee) { create(:user) }
let(:assignees) { Array.wrap(assignee) }
- let(:author) { create(:user) }
let(:merge_request) { create :merge_request, author: author, source_project: project, assignees: assignees, description: 'cc @participant' }
- before do
- project.add_maintainer(author)
- assignees.each { |assignee| project.add_maintainer(assignee) }
+ let_it_be_with_reload(:author) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :public, :repository, namespace: group) }
+
+ before_all do
build_team(project)
add_users(project)
+
+ project.add_maintainer(author)
+ project.add_maintainer(assignee)
+ end
+
+ before do
add_user_subscriptions(merge_request)
update_custom_notification(:new_merge_request, @u_guest_custom, resource: project)
update_custom_notification(:new_merge_request, @u_custom_global)
@@ -1667,13 +1692,13 @@ RSpec.describe NotificationService, :mailer do
end
context 'participating' do
- it_should_behave_like 'participating by assignee notification' do
+ it_behaves_like 'participating by assignee notification' do
let(:participant) { create(:user, username: 'user-participant')}
let(:issuable) { merge_request }
let(:notification_trigger) { notification.new_merge_request(merge_request, @u_disabled) }
end
- it_should_behave_like 'participating by note notification' do
+ it_behaves_like 'participating by note notification' do
let(:participant) { create(:user, username: 'user-participant')}
let(:issuable) { merge_request }
let(:notification_trigger) { notification.new_merge_request(merge_request, @u_disabled) }
@@ -2066,9 +2091,7 @@ RSpec.describe NotificationService, :mailer do
end
describe 'Projects', :deliver_mails_inline do
- let(:project) { create(:project) }
-
- before do
+ before_all do
build_team(project)
reset_delivered_emails!
end
@@ -2293,7 +2316,6 @@ RSpec.describe NotificationService, :mailer do
end
describe 'ProjectMember', :deliver_mails_inline do
- let(:project) { create(:project) }
let(:added_user) { create(:user) }
describe '#new_access_request' do
@@ -2327,7 +2349,6 @@ RSpec.describe NotificationService, :mailer do
end
it_behaves_like 'sends notification only to a maximum of ten, most recently active project maintainers' do
- let(:project) { create(:project, :public) }
let(:notification_trigger) { project.request_access(added_user) }
end
end
@@ -2457,7 +2478,6 @@ RSpec.describe NotificationService, :mailer do
let(:private_project) { create(:project, :private) }
let(:guest) { create(:user) }
let(:developer) { create(:user) }
- let(:assignee) { create(:user) }
let(:merge_request) { create(:merge_request, source_project: private_project, assignees: [assignee]) }
let(:merge_request1) { create(:merge_request, source_project: private_project, assignees: [assignee], description: "cc @#{guest.username}") }
let(:note) { create(:note, noteable: merge_request, project: private_project) }
@@ -2510,15 +2530,15 @@ RSpec.describe NotificationService, :mailer do
describe 'Pipelines', :deliver_mails_inline do
describe '#pipeline_finished' do
- let(:project) { create(:project, :public, :repository) }
- let(:u_member) { create(:user) }
- let(:u_watcher) { create_user_with_notification(:watch, 'watcher') }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:u_member) { create(:user) }
+ let_it_be(:u_watcher) { create_user_with_notification(:watch, 'watcher') }
- let(:u_custom_notification_unset) do
+ let_it_be(:u_custom_notification_unset) do
create_user_with_notification(:custom, 'custom_unset')
end
- let(:u_custom_notification_enabled) do
+ let_it_be(:u_custom_notification_enabled) do
user = create_user_with_notification(:custom, 'custom_enabled')
update_custom_notification(:success_pipeline, user, resource: project)
update_custom_notification(:failed_pipeline, user, resource: project)
@@ -2526,7 +2546,7 @@ RSpec.describe NotificationService, :mailer do
user
end
- let(:u_custom_notification_disabled) do
+ let_it_be(:u_custom_notification_disabled) do
user = create_user_with_notification(:custom, 'custom_disabled')
update_custom_notification(:success_pipeline, user, resource: project, value: false)
update_custom_notification(:failed_pipeline, user, resource: project, value: false)
@@ -2545,13 +2565,15 @@ RSpec.describe NotificationService, :mailer do
before_sha: '00000000')
end
- before do
+ before_all do
project.add_maintainer(u_member)
project.add_maintainer(u_watcher)
project.add_maintainer(u_custom_notification_unset)
project.add_maintainer(u_custom_notification_enabled)
project.add_maintainer(u_custom_notification_disabled)
+ end
+ before do
reset_delivered_emails!
end
@@ -2889,7 +2911,6 @@ RSpec.describe NotificationService, :mailer do
describe 'Repository cleanup', :deliver_mails_inline do
let(:user) { create(:user) }
- let(:project) { create(:project) }
describe '#repository_cleanup_success' do
it 'emails the specified user only' do
@@ -2920,7 +2941,6 @@ RSpec.describe NotificationService, :mailer do
context 'Remote mirror notifications', :deliver_mails_inline do
describe '#remote_mirror_update_failed' do
- let(:project) { create(:project) }
let(:remote_mirror) { create(:remote_mirror, project: project) }
let(:u_blocked) { create(:user, :blocked) }
let(:u_silence) { create_user_with_notification(:disabled, 'silent-maintainer', project) }
@@ -3159,11 +3179,11 @@ RSpec.describe NotificationService, :mailer do
end
def should_email_nested_group_user(user, times: 1, recipients: email_recipients)
- should_email(user, times: 1, recipients: email_recipients)
+ should_email(user, times: times, recipients: recipients)
end
def should_not_email_nested_group_user(user, recipients: email_recipients)
- should_not_email(user, recipients: email_recipients)
+ should_not_email(user, recipients: recipients)
end
def add_users(project)
diff --git a/spec/services/packages/composer/create_package_service_spec.rb b/spec/services/packages/composer/create_package_service_spec.rb
index 3f9da31cf6e..a1fe9a1b918 100644
--- a/spec/services/packages/composer/create_package_service_spec.rb
+++ b/spec/services/packages/composer/create_package_service_spec.rb
@@ -37,12 +37,16 @@ RSpec.describe Packages::Composer::CreatePackageService do
expect(created_package.composer_metadatum.target_sha).to eq branch.target
expect(created_package.composer_metadatum.composer_json.to_json).to eq json
end
+
+ it_behaves_like 'assigns the package creator' do
+ let(:package) { created_package }
+ end
end
context 'with a tag' do
let(:tag) { project.repository.find_tag('v1.2.3') }
- before do
+ before(:all) do
project.repository.add_tag(user, 'v1.2.3', 'master')
end
@@ -54,6 +58,10 @@ RSpec.describe Packages::Composer::CreatePackageService do
expect(created_package.name).to eq package_name
expect(created_package.version).to eq '1.2.3'
end
+
+ it_behaves_like 'assigns the package creator' do
+ let(:package) { created_package }
+ end
end
end
diff --git a/spec/services/packages/conan/create_package_service_spec.rb b/spec/services/packages/conan/create_package_service_spec.rb
index f8068f6e57b..b217e570aba 100644
--- a/spec/services/packages/conan/create_package_service_spec.rb
+++ b/spec/services/packages/conan/create_package_service_spec.rb
@@ -5,9 +5,11 @@ RSpec.describe Packages::Conan::CreatePackageService do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
- subject { described_class.new(project, user, params) }
+ subject(:service) { described_class.new(project, user, params) }
describe '#execute' do
+ subject(:package) { service.execute }
+
context 'valid params' do
let(:params) do
{
@@ -19,8 +21,6 @@ RSpec.describe Packages::Conan::CreatePackageService do
end
it 'creates a new package' do
- package = subject.execute
-
expect(package).to be_valid
expect(package.name).to eq(params[:package_name])
expect(package.version).to eq(params[:package_version])
@@ -28,6 +28,8 @@ RSpec.describe Packages::Conan::CreatePackageService do
expect(package.conan_metadatum.package_username).to eq(params[:package_username])
expect(package.conan_metadatum.package_channel).to eq(params[:package_channel])
end
+
+ it_behaves_like 'assigns the package creator'
end
context 'invalid params' do
@@ -41,7 +43,7 @@ RSpec.describe Packages::Conan::CreatePackageService do
end
it 'fails' do
- expect { subject.execute }.to raise_exception(ActiveRecord::RecordInvalid)
+ expect { package }.to raise_exception(ActiveRecord::RecordInvalid)
end
end
end
diff --git a/spec/services/packages/maven/create_package_service_spec.rb b/spec/services/packages/maven/create_package_service_spec.rb
index bfdf62008ba..7ec368aa00f 100644
--- a/spec/services/packages/maven/create_package_service_spec.rb
+++ b/spec/services/packages/maven/create_package_service_spec.rb
@@ -34,6 +34,8 @@ RSpec.describe Packages::Maven::CreatePackageService do
end
it_behaves_like 'assigns build to package'
+
+ it_behaves_like 'assigns the package creator'
end
context 'without version' do
@@ -57,6 +59,8 @@ RSpec.describe Packages::Maven::CreatePackageService do
expect(package.maven_metadatum.app_name).to eq(app_name)
expect(package.maven_metadatum.app_version).to be nil
end
+
+ it_behaves_like 'assigns the package creator'
end
context 'path is missing' do
diff --git a/spec/services/packages/npm/create_package_service_spec.rb b/spec/services/packages/npm/create_package_service_spec.rb
index c1391746f52..c8431c640da 100644
--- a/spec/services/packages/npm/create_package_service_spec.rb
+++ b/spec/services/packages/npm/create_package_service_spec.rb
@@ -27,6 +27,10 @@ RSpec.describe Packages::Npm::CreatePackageService do
.and change { Packages::Tag.count }.by(1)
end
+ it_behaves_like 'assigns the package creator' do
+ let(:package) { subject }
+ end
+
it { is_expected.to be_valid }
it 'creates a package with name and version' do
@@ -61,6 +65,15 @@ RSpec.describe Packages::Npm::CreatePackageService do
it { expect(subject[:message]).to be 'Package already exists.' }
end
+ context 'file size above maximum limit' do
+ before do
+ params['_attachments']["#{package_name}-#{version}.tgz"]['length'] = project.actual_limits.npm_max_file_size + 1
+ end
+
+ it { expect(subject[:http_status]).to eq 400 }
+ it { expect(subject[:message]).to be 'File is too large.' }
+ end
+
context 'with incorrect namespace' do
let(:package_name) { '@my_other_namespace/my-app' }
diff --git a/spec/services/packages/nuget/create_package_service_spec.rb b/spec/services/packages/nuget/create_package_service_spec.rb
index 1579b42d9ad..e51bc03f311 100644
--- a/spec/services/packages/nuget/create_package_service_spec.rb
+++ b/spec/services/packages/nuget/create_package_service_spec.rb
@@ -9,9 +9,10 @@ RSpec.describe Packages::Nuget::CreatePackageService do
describe '#execute' do
subject { described_class.new(project, user, params).execute }
+ let(:package) { Packages::Package.last }
+
it 'creates the package' do
expect { subject }.to change { Packages::Package.count }.by(1)
- package = Packages::Package.last
expect(package).to be_valid
expect(package.name).to eq(Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME)
@@ -23,12 +24,12 @@ RSpec.describe Packages::Nuget::CreatePackageService do
expect { subject }.to change { Packages::Package.count }.by(1)
expect { described_class.new(project, user, params).execute }.to change { Packages::Package.count }.by(1)
- package = Packages::Package.last
-
expect(package).to be_valid
expect(package.name).to eq(Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME)
expect(package.version).to start_with(Packages::Nuget::CreatePackageService::PACKAGE_VERSION)
expect(package.package_type).to eq('nuget')
end
+
+ it_behaves_like 'assigns the package creator'
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 bfecb32f9ef..c985c1e54ea 100644
--- a/spec/services/packages/pypi/create_package_service_spec.rb
+++ b/spec/services/packages/pypi/create_package_service_spec.rb
@@ -6,12 +6,14 @@ RSpec.describe Packages::Pypi::CreatePackageService do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
- let_it_be(:params) do
+
+ let(:requires_python) { '>=2.7' }
+ let(:params) do
{
name: 'foo',
version: '1.0',
content: temp_file('foo.tgz'),
- requires_python: '>=2.7',
+ requires_python: requires_python,
sha256_digest: '123',
md5_digest: '567'
}
@@ -37,6 +39,18 @@ RSpec.describe Packages::Pypi::CreatePackageService do
end
end
+ context 'with an invalid metadata' do
+ let(:requires_python) { 'x' * 256 }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+ end
+
+ it_behaves_like 'assigns the package creator' do
+ let(:package) { created_package }
+ end
+
context 'with an existing package' do
before do
described_class.new(project, user, params).execute
diff --git a/spec/services/pages/delete_services_spec.rb b/spec/services/pages/delete_services_spec.rb
index f6d4694b4dd..440549020a2 100644
--- a/spec/services/pages/delete_services_spec.rb
+++ b/spec/services/pages/delete_services_spec.rb
@@ -3,25 +3,35 @@
require 'spec_helper'
RSpec.describe Pages::DeleteService do
- let_it_be(:project) { create(:project, path: "my.project")}
- let_it_be(:admin) { create(:admin) }
- let_it_be(:domain) { create(:pages_domain, project: project) }
- let_it_be(:service) { described_class.new(project, admin)}
+ shared_examples 'remove pages' do
+ let_it_be(:project) { create(:project, path: "my.project")}
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:domain) { create(:pages_domain, project: project) }
+ let_it_be(:service) { described_class.new(project, admin)}
- it 'deletes published pages' do
- expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true
- expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything)
+ it 'deletes published pages' do
+ expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true
+ expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything)
- service.execute
+ Sidekiq::Testing.inline! { service.execute }
- expect(project.reload.pages_metadatum.deployed?).to be(false)
- end
+ expect(project.reload.pages_metadatum.deployed?).to be(false)
+ end
+
+ it 'deletes all domains' do
+ expect(project.pages_domains.count).to be 1
- it 'deletes all domains' do
- expect(project.pages_domains.count).to be 1
+ Sidekiq::Testing.inline! { service.execute }
+
+ expect(project.reload.pages_domains.count).to be 0
+ end
+ end
- service.execute
+ context 'with feature flag enabled' do
+ before do
+ expect(PagesRemoveWorker).to receive(:perform_async).and_call_original
+ end
- expect(project.reload.pages_domains.count).to be 0
+ it_behaves_like 'remove pages'
end
end
diff --git a/spec/services/product_analytics/build_activity_graph_service_spec.rb b/spec/services/product_analytics/build_activity_graph_service_spec.rb
new file mode 100644
index 00000000000..e303656da34
--- /dev/null
+++ b/spec/services/product_analytics/build_activity_graph_service_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ProductAnalytics::BuildActivityGraphService 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 }
+
+ let_it_be(:events) do
+ [
+ create(:product_analytics_event, project: project, collector_tstamp: time_now),
+ create(:product_analytics_event, project: project, collector_tstamp: time_now),
+ create(:product_analytics_event, project: project, collector_tstamp: time_now),
+ create(:product_analytics_event, project: project, collector_tstamp: time_ago),
+ create(:product_analytics_event, project: project, collector_tstamp: time_ago)
+ ]
+ end
+
+ let(:params) { { timerange: 7 } }
+
+ subject { described_class.new(project, params).execute }
+
+ it 'returns a valid graph hash' do
+ expected_hash = {
+ id: 'collector_tstamp',
+ keys: [time_ago.to_date, time_now.to_date],
+ values: [2, 3]
+ }
+
+ expect(subject).to eq(expected_hash)
+ end
+end
diff --git a/spec/services/projects/after_rename_service_spec.rb b/spec/services/projects/after_rename_service_spec.rb
index 52136b37c66..f03e1ed0e22 100644
--- a/spec/services/projects/after_rename_service_spec.rb
+++ b/spec/services/projects/after_rename_service_spec.rb
@@ -22,7 +22,6 @@ RSpec.describe Projects::AfterRenameService do
# call. This makes testing a bit easier.
allow(project).to receive(:gitlab_shell).and_return(gitlab_shell)
- stub_feature_flags(skip_hashed_storage_upgrade: false)
stub_application_setting(hashed_storage_enabled: false)
end
@@ -62,13 +61,28 @@ RSpec.describe Projects::AfterRenameService do
context 'gitlab pages' do
before do
- expect(project_storage).to receive(:rename_repo) { true }
+ allow(project_storage).to receive(:rename_repo) { true }
end
- it 'moves pages folder to new location' do
- expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project)
+ context 'when the project has pages deployed' do
+ it 'schedules a move of the pages directory' do
+ allow(project).to receive(:pages_deployed?).and_return(true)
- service_execute
+ expect(PagesTransferWorker).to receive(:perform_async).with('rename_project', anything)
+
+ service_execute
+ end
+ end
+
+ context 'when the project does not have pages deployed' do
+ it 'does nothing with the pages directory' do
+ allow(project).to receive(:pages_deployed?).and_return(false)
+
+ expect(PagesTransferWorker).not_to receive(:perform_async)
+ expect(Gitlab::PagesTransfer).not_to receive(:new)
+
+ service_execute
+ end
end
end
@@ -126,7 +140,6 @@ RSpec.describe Projects::AfterRenameService do
# call. This makes testing a bit easier.
allow(project).to receive(:gitlab_shell).and_return(gitlab_shell)
- stub_feature_flags(skip_hashed_storage_upgrade: false)
stub_application_setting(hashed_storage_enabled: true)
end
@@ -160,10 +173,25 @@ RSpec.describe Projects::AfterRenameService do
end
context 'gitlab pages' do
- it 'moves pages folder to new location' do
- expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project)
+ context 'when the project has pages deployed' do
+ it 'schedules a move of the pages directory' do
+ allow(project).to receive(:pages_deployed?).and_return(true)
- service_execute
+ expect(PagesTransferWorker).to receive(:perform_async).with('rename_project', anything)
+
+ service_execute
+ end
+ end
+
+ context 'when the project does not have pages deployed' do
+ it 'does nothing with the pages directory' do
+ allow(project).to receive(:pages_deployed?).and_return(false)
+
+ expect(PagesTransferWorker).not_to receive(:perform_async)
+ expect(Gitlab::PagesTransfer).not_to receive(:new)
+
+ service_execute
+ end
end
end
diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb
index 3e74a15c3c0..77a0e330109 100644
--- a/spec/services/projects/alerting/notify_service_spec.rb
+++ b/spec/services/projects/alerting/notify_service_spec.rb
@@ -3,67 +3,32 @@
require 'spec_helper'
RSpec.describe Projects::Alerting::NotifyService do
- let_it_be(:project, reload: true) { create(:project) }
+ let_it_be_with_reload(:project) { create(:project, :repository) }
before do
- # We use `let_it_be(:project)` so we make sure to clear caches
- project.clear_memoization(:licensed_feature_available)
allow(ProjectServiceWorker).to receive(:perform_async)
end
- shared_examples 'processes incident issues' do
- let(:create_incident_service) { spy }
-
- before do
- allow_any_instance_of(AlertManagement::Alert).to receive(:execute_services)
- end
-
- it 'processes issues' do
- expect(IncidentManagement::ProcessAlertWorker)
- .to receive(:perform_async)
- .with(nil, nil, kind_of(Integer))
- .once
-
- Sidekiq::Testing.inline! do
- expect(subject).to be_success
- end
- end
- end
-
- shared_examples 'does not process incident issues' do
- it 'does not process issues' do
- expect(IncidentManagement::ProcessAlertWorker)
- .not_to receive(:perform_async)
-
- expect(subject).to be_success
- end
- end
-
- shared_examples 'does not process incident issues due to error' do |http_status:|
- it 'does not process issues' do
- expect(IncidentManagement::ProcessAlertWorker)
- .not_to receive(:perform_async)
-
- expect(subject).to be_error
- expect(subject.http_status).to eq(http_status)
- end
- end
-
describe '#execute' do
let(:token) { 'invalid-token' }
let(:starts_at) { Time.current.change(usec: 0) }
let(:fingerprint) { 'testing' }
let(:service) { described_class.new(project, nil, payload) }
+ let_it_be(:environment) { create(:environment, project: project) }
+ let(:environment) { create(:environment, project: project) }
+ let(:ended_at) { nil }
let(:payload_raw) do
{
title: 'alert title',
start_time: starts_at.rfc3339,
+ end_time: ended_at&.rfc3339,
severity: 'low',
monitoring_tool: 'GitLab RSpec',
service: 'GitLab Test Suite',
description: 'Very detailed description',
hosts: ['1.1.1.1', '2.2.2.2'],
- fingerprint: fingerprint
+ fingerprint: fingerprint,
+ gitlab_environment_name: environment.name
}.with_indifferent_access
end
@@ -72,13 +37,14 @@ RSpec.describe Projects::Alerting::NotifyService do
subject { service.execute(token) }
context 'with activated Alerts Service' do
- let!(:alerts_service) { create(:alerts_service, project: project) }
+ let_it_be_with_reload(:alerts_service) { create(:alerts_service, project: project) }
context 'with valid token' do
let(:token) { alerts_service.token }
- let(:incident_management_setting) { double(send_email?: email_enabled, create_issue?: issue_enabled) }
+ let(:incident_management_setting) { double(send_email?: email_enabled, create_issue?: issue_enabled, auto_close_incident?: auto_close_enabled) }
let(:email_enabled) { false }
let(:issue_enabled) { false }
+ let(:auto_close_enabled) { false }
before do
allow(service)
@@ -105,9 +71,9 @@ RSpec.describe Projects::Alerting::NotifyService do
monitoring_tool: payload_raw.fetch(:monitoring_tool),
service: payload_raw.fetch(:service),
fingerprint: Digest::SHA1.hexdigest(fingerprint),
+ environment_id: environment.id,
ended_at: nil,
- prometheus_alert_id: nil,
- environment_id: nil
+ prometheus_alert_id: nil
)
end
end
@@ -121,12 +87,67 @@ RSpec.describe Projects::Alerting::NotifyService do
it_behaves_like 'creates an alert management alert'
it_behaves_like 'assigns the alert properties'
+ it 'creates a system note corresponding to alert creation' do
+ expect { subject }.to change(Note, :count).by(1)
+ end
+
context 'existing alert with same fingerprint' do
let(:fingerprint_sha) { Digest::SHA1.hexdigest(fingerprint) }
let!(:alert) { create(:alert_management_alert, project: project, fingerprint: fingerprint_sha) }
it_behaves_like 'adds an alert management alert event'
+ context 'end time given' do
+ let(:ended_at) { Time.current.change(nsec: 0) }
+
+ it 'does not resolve the alert' do
+ expect { subject }.not_to change { alert.reload.status }
+ end
+
+ it 'does not set the ended at' do
+ subject
+
+ expect(alert.reload.ended_at).to be_nil
+ end
+
+ it_behaves_like 'does not an create alert management alert'
+
+ context 'auto_close_enabled setting enabled' do
+ let(:auto_close_enabled) { true }
+
+ it 'resolves the alert and sets the end time', :aggregate_failures do
+ subject
+ alert.reload
+
+ expect(alert.resolved?).to eq(true)
+ expect(alert.ended_at).to eql(ended_at)
+ end
+
+ context 'related issue exists' do
+ let(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: fingerprint_sha) }
+ let(:issue) { alert.issue }
+
+ context 'state_tracking is enabled' do
+ before do
+ stub_feature_flags(track_resource_state_change_events: true)
+ end
+
+ it { expect { subject }.to change { issue.reload.state }.from('opened').to('closed') }
+ it { expect { subject }.to change(ResourceStateEvent, :count).by(1) }
+ end
+
+ context 'state_tracking is disabled' do
+ before do
+ stub_feature_flags(track_resource_state_change_events: false)
+ end
+
+ it { expect { subject }.to change { issue.reload.state }.from('opened').to('closed') }
+ it { expect { subject }.to change(Note, :count).by(1) }
+ end
+ end
+ end
+ end
+
context 'existing alert is resolved' do
let!(:alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: fingerprint_sha) }
@@ -148,6 +169,13 @@ RSpec.describe Projects::Alerting::NotifyService do
end
end
+ context 'end time given' do
+ let(:ended_at) { Time.current }
+
+ it_behaves_like 'creates an alert management alert'
+ it_behaves_like 'assigns the alert properties'
+ end
+
context 'with a minimal payload' do
let(:payload_raw) do
{
@@ -183,6 +211,18 @@ RSpec.describe Projects::Alerting::NotifyService do
end
end
+ context 'with overlong payload' do
+ let(:payload_raw) do
+ {
+ title: 'a' * Gitlab::Utils::DeepSize::DEFAULT_MAX_SIZE,
+ start_time: starts_at.rfc3339
+ }
+ end
+
+ it_behaves_like 'does not process incident issues due to error', http_status: :bad_request
+ it_behaves_like 'does not an create alert management alert'
+ end
+
it_behaves_like 'does not process incident issues'
context 'issue enabled' do
@@ -230,7 +270,9 @@ RSpec.describe Projects::Alerting::NotifyService do
end
context 'with deactivated Alerts Service' do
- let!(:alerts_service) { create(:alerts_service, :inactive, project: project) }
+ before do
+ alerts_service.update!(active: false)
+ end
it_behaves_like 'does not process incident issues due to error', http_status: :forbidden
it_behaves_like 'does not an create alert management alert'
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 3014ccbd7ba..5116427dad2 100644
--- a/spec/services/projects/container_repository/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb
@@ -90,6 +90,10 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
subject { service.execute(repository) }
+ before do
+ stub_feature_flags(container_registry_expiration_policies_throttling: false)
+ end
+
context 'without permissions' do
it { is_expected.to include(status: :error) }
end
@@ -119,6 +123,18 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
it_behaves_like 'logging a success response'
end
+
+ context 'with a timeout error' do
+ before do
+ expect_next_instance_of(::Projects::ContainerRepository::Gitlab::DeleteTagsService) do |delete_service|
+ expect(delete_service).to receive(:delete_tags).and_raise(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError)
+ end
+ end
+
+ it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
+
+ it_behaves_like 'logging an error response', message: 'timeout while deleting tags'
+ end
end
context 'and the feature is disabled' do
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 68c232e5d83..3bbcec8775e 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
@@ -12,13 +12,21 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
subject { service.execute }
- context 'with tags to delete' do
+ before do
+ stub_feature_flags(container_registry_expiration_policies_throttling: false)
+ end
+
+ RSpec.shared_examples 'deleting tags' do
it 'deletes the tags by name' do
stub_delete_reference_requests(tags)
expect_delete_tag_by_names(tags)
is_expected.to eq(status: :success, deleted: tags)
end
+ end
+
+ context 'with tags to delete' do
+ it_behaves_like 'deleting tags'
it 'succeeds when tag delete returns 404' do
stub_delete_reference_requests('A' => 200, 'Ba' => 404)
@@ -41,6 +49,47 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
it { is_expected.to eq(status: :error, message: 'could not delete tags') }
end
end
+
+ context 'with throttling enabled' do
+ let(:timeout) { 10 }
+
+ before do
+ stub_feature_flags(container_registry_expiration_policies_throttling: true)
+ stub_application_setting(container_registry_delete_tags_service_timeout: timeout)
+ end
+
+ it_behaves_like 'deleting tags'
+
+ context 'with timeout' do
+ context 'set to a valid value' do
+ before do
+ allow(Time.zone).to receive(:now).and_return(10, 15, 25) # third call to Time.zone.now will be triggering the timeout
+ stub_delete_reference_requests('A' => 200)
+ end
+
+ it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
+
+ it 'tracks the exception' do
+ expect(::Gitlab::ErrorTracking)
+ .to receive(:track_exception).with(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError, tags_count: tags.size, container_repository_id: repository.id)
+
+ subject
+ end
+ end
+
+ context 'set to 0' do
+ let(:timeout) { 0 }
+
+ it_behaves_like 'deleting tags'
+ end
+
+ context 'set to nil' do
+ let(:timeout) { nil }
+
+ it_behaves_like 'deleting tags'
+ end
+ end
+ end
end
context 'with empty tags' do
diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb
index 56b19c33ece..a3711c9e17f 100644
--- a/spec/services/projects/destroy_service_spec.rb
+++ b/spec/services/projects/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::DestroyService do
+RSpec.describe Projects::DestroyService, :aggregate_failures do
include ProjectForksHelper
let_it_be(:user) { create(:user) }
@@ -60,317 +60,353 @@ RSpec.describe Projects::DestroyService do
end
end
- it_behaves_like 'deleting the project'
-
- it 'invalidates personal_project_count cache' do
- expect(user).to receive(:invalidate_personal_projects_count)
-
- destroy_project(project, user, {})
- end
-
- context 'when project has remote mirrors' do
- let!(:project) do
- create(:project, :repository, namespace: user.namespace).tap do |project|
- project.remote_mirrors.create(url: 'http://test.com')
- end
- end
+ shared_examples 'project destroy' do
+ it_behaves_like 'deleting the project'
- it 'destroys them' do
- expect(RemoteMirror.count).to eq(1)
+ it 'invalidates personal_project_count cache' do
+ expect(user).to receive(:invalidate_personal_projects_count)
destroy_project(project, user, {})
-
- expect(RemoteMirror.count).to eq(0)
end
- end
- context 'when project has exports' do
- let!(:project_with_export) do
- create(:project, :repository, namespace: user.namespace).tap do |project|
- create(:import_export_upload,
- project: project,
- export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz'))
+ context 'when project has remote mirrors' do
+ let!(:project) do
+ create(:project, :repository, namespace: user.namespace).tap do |project|
+ project.remote_mirrors.create(url: 'http://test.com')
+ end
end
- end
- it 'destroys project and export' do
- expect do
- destroy_project(project_with_export, user, {})
- end.to change(ImportExportUpload, :count).by(-1)
+ it 'destroys them' do
+ expect(RemoteMirror.count).to eq(1)
- expect(Project.all).not_to include(project_with_export)
- end
- end
+ destroy_project(project, user, {})
- context 'Sidekiq fake' do
- before do
- # Dont run sidekiq to check if renamed repository exists
- Sidekiq::Testing.fake! { destroy_project(project, user, {}) }
+ expect(RemoteMirror.count).to eq(0)
+ end
end
- it { expect(Project.all).not_to include(project) }
-
- it do
- expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
- end
+ context 'when project has exports' do
+ let!(:project_with_export) do
+ create(:project, :repository, namespace: user.namespace).tap do |project|
+ create(:import_export_upload,
+ project: project,
+ export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz'))
+ end
+ end
- it do
- expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_truthy
- end
- end
+ it 'destroys project and export' do
+ expect do
+ destroy_project(project_with_export, user, {})
+ end.to change(ImportExportUpload, :count).by(-1)
- context 'when flushing caches fail due to Git errors' do
- before do
- allow(project.repository).to receive(:before_delete).and_raise(::Gitlab::Git::CommandError)
- allow(Gitlab::GitLogger).to receive(:warn).with(
- class: Repositories::DestroyService.name,
- container_id: project.id,
- disk_path: project.disk_path,
- message: 'Gitlab::Git::CommandError').and_call_original
+ expect(Project.all).not_to include(project_with_export)
+ end
end
- it_behaves_like 'deleting the project'
- end
+ context 'Sidekiq fake' do
+ before do
+ # Dont run sidekiq to check if renamed repository exists
+ Sidekiq::Testing.fake! { destroy_project(project, user, {}) }
+ end
- context 'when flushing caches fail due to Redis' do
- before do
- new_user = create(:user)
- project.team.add_user(new_user, Gitlab::Access::DEVELOPER)
- allow_any_instance_of(described_class).to receive(:flush_caches).and_raise(::Redis::CannotConnectError)
- end
+ it { expect(Project.all).not_to include(project) }
- it 'keeps project team intact upon an error' do
- perform_enqueued_jobs do
- destroy_project(project, user, {})
- rescue ::Redis::CannotConnectError
+ it do
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
end
- expect(project.team.members.count).to eq 2
+ it do
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_truthy
+ end
end
- end
- context 'with async_execute', :sidekiq_inline do
- let(:async) { true }
-
- context 'async delete of project with private issue visibility' do
+ context 'when flushing caches fail due to Git errors' do
before do
- project.project_feature.update_attribute("issues_access_level", ProjectFeature::PRIVATE)
+ allow(project.repository).to receive(:before_delete).and_raise(::Gitlab::Git::CommandError)
+ allow(Gitlab::GitLogger).to receive(:warn).with(
+ class: Repositories::DestroyService.name,
+ container_id: project.id,
+ disk_path: project.disk_path,
+ message: 'Gitlab::Git::CommandError').and_call_original
end
it_behaves_like 'deleting the project'
end
- it_behaves_like 'deleting the project with pipeline and build'
+ context 'when flushing caches fail due to Redis' do
+ before do
+ new_user = create(:user)
+ project.team.add_user(new_user, Gitlab::Access::DEVELOPER)
+ allow_any_instance_of(described_class).to receive(:flush_caches).and_raise(::Redis::CannotConnectError)
+ end
- context 'errors' do
- context 'when `remove_legacy_registry_tags` fails' do
- before do
- expect_any_instance_of(described_class)
- .to receive(:remove_legacy_registry_tags).and_return(false)
+ it 'keeps project team intact upon an error' do
+ perform_enqueued_jobs do
+ destroy_project(project, user, {})
+ rescue ::Redis::CannotConnectError
end
- it_behaves_like 'handles errors thrown during async destroy', "Failed to remove some tags"
+ expect(project.team.members.count).to eq 2
end
+ end
+
+ context 'with async_execute', :sidekiq_inline do
+ let(:async) { true }
- context 'when `remove_repository` fails' do
+ context 'async delete of project with private issue visibility' do
before do
- expect_any_instance_of(described_class)
- .to receive(:remove_repository).and_return(false)
+ project.project_feature.update_attribute("issues_access_level", ProjectFeature::PRIVATE)
end
- it_behaves_like 'handles errors thrown during async destroy', "Failed to remove project repository"
+ it_behaves_like 'deleting the project'
end
- context 'when `execute` raises expected error' do
- before do
- expect_any_instance_of(Project)
- .to receive(:destroy!).and_raise(StandardError.new("Other error message"))
+ it_behaves_like 'deleting the project with pipeline and build'
+
+ context 'errors' do
+ context 'when `remove_legacy_registry_tags` fails' do
+ before do
+ expect_any_instance_of(described_class)
+ .to receive(:remove_legacy_registry_tags).and_return(false)
+ end
+
+ it_behaves_like 'handles errors thrown during async destroy', "Failed to remove some tags"
end
- it_behaves_like 'handles errors thrown during async destroy', "Other error message"
- end
+ context 'when `remove_repository` fails' do
+ before do
+ expect_any_instance_of(described_class)
+ .to receive(:remove_repository).and_return(false)
+ end
- context 'when `execute` raises unexpected error' do
- before do
- expect_any_instance_of(Project)
- .to receive(:destroy!).and_raise(Exception.new('Other error message'))
+ it_behaves_like 'handles errors thrown during async destroy', "Failed to remove project repository"
end
- it 'allows error to bubble up and rolls back project deletion' do
- expect do
- destroy_project(project, user, {})
- end.to raise_error(Exception, 'Other error message')
+ context 'when `execute` raises expected error' do
+ before do
+ expect_any_instance_of(Project)
+ .to receive(:destroy!).and_raise(StandardError.new("Other error message"))
+ end
- expect(project.reload.pending_delete).to be(false)
- expect(project.delete_error).to include("Other error message")
+ it_behaves_like 'handles errors thrown during async destroy', "Other error message"
end
- end
- end
- end
- describe 'container registry' do
- context 'when there are regular container repositories' do
- let(:container_repository) { create(:container_repository) }
+ context 'when `execute` raises unexpected error' do
+ before do
+ expect_any_instance_of(Project)
+ .to receive(:destroy!).and_raise(Exception.new('Other error message'))
+ end
- before do
- stub_container_registry_tags(repository: project.full_path + '/image',
- tags: ['tag'])
- project.container_repositories << container_repository
+ it 'allows error to bubble up and rolls back project deletion' do
+ expect do
+ destroy_project(project, user, {})
+ end.to raise_error(Exception, 'Other error message')
+
+ expect(project.reload.pending_delete).to be(false)
+ expect(project.delete_error).to include("Other error message")
+ end
+ end
end
+ end
- context 'when image repository deletion succeeds' do
- it 'removes tags' do
- expect_any_instance_of(ContainerRepository)
- .to receive(:delete_tags!).and_return(true)
+ describe 'container registry' do
+ context 'when there are regular container repositories' do
+ let(:container_repository) { create(:container_repository) }
- destroy_project(project, user)
+ before do
+ stub_container_registry_tags(repository: project.full_path + '/image',
+ tags: ['tag'])
+ project.container_repositories << container_repository
end
- end
- context 'when image repository deletion fails' do
- it 'raises an exception' do
- expect_any_instance_of(ContainerRepository)
- .to receive(:delete_tags!).and_raise(RuntimeError)
+ context 'when image repository deletion succeeds' do
+ it 'removes tags' do
+ expect_any_instance_of(ContainerRepository)
+ .to receive(:delete_tags!).and_return(true)
- expect(destroy_project(project, user)).to be false
+ destroy_project(project, user)
+ end
end
- end
- context 'when registry is disabled' do
- before do
- stub_container_registry_config(enabled: false)
+ context 'when image repository deletion fails' do
+ it 'raises an exception' do
+ expect_any_instance_of(ContainerRepository)
+ .to receive(:delete_tags!).and_raise(RuntimeError)
+
+ expect(destroy_project(project, user)).to be false
+ end
end
- it 'does not attempting to remove any tags' do
- expect(Projects::ContainerRepository::DestroyService).not_to receive(:new)
+ context 'when registry is disabled' do
+ before do
+ stub_container_registry_config(enabled: false)
+ end
- destroy_project(project, user)
+ it 'does not attempting to remove any tags' do
+ expect(Projects::ContainerRepository::DestroyService).not_to receive(:new)
+
+ destroy_project(project, user)
+ end
end
end
- end
- context 'when there are tags for legacy root repository' do
- before do
- stub_container_registry_tags(repository: project.full_path,
- tags: ['tag'])
- end
+ context 'when there are tags for legacy root repository' do
+ before do
+ stub_container_registry_tags(repository: project.full_path,
+ tags: ['tag'])
+ end
- context 'when image repository tags deletion succeeds' do
- it 'removes tags' do
- expect_any_instance_of(ContainerRepository)
- .to receive(:delete_tags!).and_return(true)
+ context 'when image repository tags deletion succeeds' do
+ it 'removes tags' do
+ expect_any_instance_of(ContainerRepository)
+ .to receive(:delete_tags!).and_return(true)
- destroy_project(project, user)
+ destroy_project(project, user)
+ end
end
- end
- context 'when image repository tags deletion fails' do
- it 'raises an exception' do
- expect_any_instance_of(ContainerRepository)
- .to receive(:delete_tags!).and_return(false)
+ context 'when image repository tags deletion fails' do
+ it 'raises an exception' do
+ expect_any_instance_of(ContainerRepository)
+ .to receive(:delete_tags!).and_return(false)
- expect(destroy_project(project, user)).to be false
+ expect(destroy_project(project, user)).to be false
+ end
end
end
end
- end
- context 'for a forked project with LFS objects' do
- let(:forked_project) { fork_project(project, user) }
+ context 'for a forked project with LFS objects' do
+ let(:forked_project) { fork_project(project, user) }
- before do
- project.lfs_objects << create(:lfs_object)
- forked_project.reload
- end
+ before do
+ project.lfs_objects << create(:lfs_object)
+ forked_project.reload
+ end
- it 'destroys the fork' do
- expect { destroy_project(forked_project, user) }
- .not_to raise_error
+ it 'destroys the fork' do
+ expect { destroy_project(forked_project, user) }
+ .not_to raise_error
+ end
end
- end
- context 'as the root of a fork network' do
- let!(:fork_1) { fork_project(project, user) }
- let!(:fork_2) { fork_project(project, user) }
+ context 'as the root of a fork network' do
+ let!(:fork_1) { fork_project(project, user) }
+ let!(:fork_2) { fork_project(project, user) }
- it 'updates the fork network with the project name' do
- fork_network = project.fork_network
+ it 'updates the fork network with the project name' do
+ fork_network = project.fork_network
- destroy_project(project, user)
+ destroy_project(project, user)
- fork_network.reload
+ fork_network.reload
- expect(fork_network.deleted_root_project_name).to eq(project.full_name)
- expect(fork_network.root_project).to be_nil
+ expect(fork_network.deleted_root_project_name).to eq(project.full_name)
+ expect(fork_network.root_project).to be_nil
+ end
end
- end
- context 'repository +deleted path removal' do
- context 'regular phase' do
- it 'schedules +deleted removal of existing repos' do
- service = described_class.new(project, user, {})
- allow(service).to receive(:schedule_stale_repos_removal)
+ context 'repository +deleted path removal' do
+ context 'regular phase' do
+ it 'schedules +deleted removal of existing repos' do
+ service = described_class.new(project, user, {})
+ allow(service).to receive(:schedule_stale_repos_removal)
- expect(Repositories::ShellDestroyService).to receive(:new).and_call_original
- expect(GitlabShellWorker).to receive(:perform_in)
- .with(5.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path))
+ expect(Repositories::ShellDestroyService).to receive(:new).and_call_original
+ expect(GitlabShellWorker).to receive(:perform_in)
+ .with(5.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path))
- service.execute
+ service.execute
+ end
end
- end
- context 'stale cleanup' do
- let(:async) { true }
+ context 'stale cleanup' do
+ let(:async) { true }
- it 'schedules +deleted wiki and repo removal' do
- allow(ProjectDestroyWorker).to receive(:perform_async)
+ it 'schedules +deleted wiki and repo removal' do
+ allow(ProjectDestroyWorker).to receive(:perform_async)
- expect(Repositories::ShellDestroyService).to receive(:new).with(project.repository).and_call_original
- expect(GitlabShellWorker).to receive(:perform_in)
- .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path))
+ expect(Repositories::ShellDestroyService).to receive(:new).with(project.repository).and_call_original
+ expect(GitlabShellWorker).to receive(:perform_in)
+ .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path))
- expect(Repositories::ShellDestroyService).to receive(:new).with(project.wiki.repository).and_call_original
- expect(GitlabShellWorker).to receive(:perform_in)
- .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.wiki.disk_path))
+ expect(Repositories::ShellDestroyService).to receive(:new).with(project.wiki.repository).and_call_original
+ expect(GitlabShellWorker).to receive(:perform_in)
+ .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.wiki.disk_path))
- destroy_project(project, user, {})
+ destroy_project(project, user, {})
+ end
end
end
- end
- context 'snippets' do
- let!(:snippet1) { create(:project_snippet, project: project, author: user) }
- let!(:snippet2) { create(:project_snippet, project: project, author: user) }
+ context 'snippets' do
+ let!(:snippet1) { create(:project_snippet, project: project, author: user) }
+ let!(:snippet2) { create(:project_snippet, project: project, author: user) }
- it 'does not include snippets when deleting in batches' do
- expect(project).to receive(:destroy_dependent_associations_in_batches).with({ exclude: [:container_repositories, :snippets] })
+ it 'does not include snippets when deleting in batches' do
+ expect(project).to receive(:destroy_dependent_associations_in_batches).with({ exclude: [:container_repositories, :snippets] })
- destroy_project(project, user)
- end
+ destroy_project(project, user)
+ end
- it 'calls the bulk snippet destroy service' do
- expect(project.snippets.count).to eq 2
+ it 'calls the bulk snippet destroy service' do
+ expect(project.snippets.count).to eq 2
- expect(Snippets::BulkDestroyService).to receive(:new)
- .with(user, project.snippets).and_call_original
+ expect(Snippets::BulkDestroyService).to receive(:new)
+ .with(user, project.snippets).and_call_original
- expect do
- destroy_project(project, user)
- end.to change(Snippet, :count).by(-2)
- end
+ expect do
+ destroy_project(project, user)
+ end.to change(Snippet, :count).by(-2)
+ end
- context 'when an error is raised deleting snippets' do
- it 'does not delete project' do
- allow_next_instance_of(Snippets::BulkDestroyService) do |instance|
- allow(instance).to receive(:execute).and_return(ServiceResponse.error(message: 'foo'))
+ context 'when an error is raised deleting snippets' do
+ it 'does not delete project' do
+ allow_next_instance_of(Snippets::BulkDestroyService) do |instance|
+ allow(instance).to receive(:execute).and_return(ServiceResponse.error(message: 'foo'))
+ end
+
+ expect(destroy_project(project, user)).to be_falsey
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_truthy
end
+ end
+ end
- expect(destroy_project(project, user)).to be_falsey
- expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_truthy
+ context 'error while destroying', :sidekiq_inline do
+ let!(:pipeline) { create(:ci_pipeline, project: project) }
+ let!(:builds) { create_list(:ci_build, 2, :artifacts, pipeline: pipeline) }
+ let!(:build_trace) { create(:ci_build_trace_chunk, build: builds[0]) }
+
+ it 'deletes on retry' do
+ # We can expect this to timeout for very large projects
+ # TODO: remove allow_next_instance_of: https://gitlab.com/gitlab-org/gitlab/-/issues/220440
+ allow_any_instance_of(Ci::Build).to receive(:destroy).and_raise('boom')
+ destroy_project(project, user, {})
+
+ allow_any_instance_of(Ci::Build).to receive(:destroy).and_call_original
+ destroy_project(project, user, {})
+
+ expect(Project.unscoped.all).not_to include(project)
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_falsey
+ expect(project.all_pipelines).to be_empty
+ expect(project.builds).to be_empty
end
end
end
+ context 'when project_transactionless_destroy enabled' do
+ it_behaves_like 'project destroy'
+ end
+
+ context 'when project_transactionless_destroy disabled', :sidekiq_inline do
+ before do
+ stub_feature_flags(project_transactionless_destroy: false)
+ end
+
+ it_behaves_like 'project destroy'
+ end
+
def destroy_project(project, user, params = {})
described_class.new(project, user, params).public_send(async ? :async_execute : :execute)
end
diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb
index 925c2ff5d88..166a2dae55b 100644
--- a/spec/services/projects/fork_service_spec.rb
+++ b/spec/services/projects/fork_service_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Projects::ForkService do
it 'flushes the forks count cache of the source project', :clean_gitlab_redis_cache do
expect(from_project.forks_count).to be_zero
- fork_project(from_project, to_user)
+ fork_project(from_project, to_user, using_service: true)
BatchLoader::Executor.clear_current
expect(from_project.forks_count).to eq(1)
@@ -40,7 +40,7 @@ RSpec.describe Projects::ForkService do
@guest = create(:user)
@from_project.add_user(@guest, :guest)
end
- subject { fork_project(@from_project, @guest) }
+ subject { fork_project(@from_project, @guest, using_service: true) }
it { is_expected.not_to be_persisted }
it { expect(subject.errors[:forked_from_project_id]).to eq(['is forbidden']) }
@@ -56,7 +56,7 @@ RSpec.describe Projects::ForkService do
end
describe "successfully creates project in the user namespace" do
- let(:to_project) { fork_project(@from_project, @to_user, namespace: @to_user.namespace) }
+ let(:to_project) { fork_project(@from_project, @to_user, namespace: @to_user.namespace, using_service: true) }
it { expect(to_project).to be_persisted }
it { expect(to_project.errors).to be_empty }
@@ -92,21 +92,21 @@ RSpec.describe Projects::ForkService do
end
it 'imports the repository of the forked project', :sidekiq_might_not_need_inline do
- to_project = fork_project(@from_project, @to_user, repository: true)
+ to_project = fork_project(@from_project, @to_user, repository: true, using_service: true)
expect(to_project.empty_repo?).to be_falsy
end
end
context 'creating a fork of a fork' do
- let(:from_forked_project) { fork_project(@from_project, @to_user) }
+ let(:from_forked_project) { fork_project(@from_project, @to_user, using_service: true) }
let(:other_namespace) do
group = create(:group)
group.add_owner(@to_user)
group
end
- let(:to_project) { fork_project(from_forked_project, @to_user, namespace: other_namespace) }
+ let(:to_project) { fork_project(from_forked_project, @to_user, namespace: other_namespace, using_service: true) }
it 'sets the root of the network to the root project' do
expect(to_project.fork_network.root_project).to eq(@from_project)
@@ -126,7 +126,7 @@ 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)
- @to_project = fork_project(@from_project, @to_user, 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
@@ -137,7 +137,7 @@ RSpec.describe Projects::ForkService do
context 'repository in legacy storage already exists' do
let(:fake_repo_path) { File.join(TestEnv.repos_path, @to_user.namespace.full_path, "#{@from_project.path}.git") }
- let(:params) { { namespace: @to_user.namespace } }
+ let(:params) { { namespace: @to_user.namespace, using_service: true } }
before do
stub_application_setting(hashed_storage_enabled: false)
@@ -169,13 +169,13 @@ RSpec.describe Projects::ForkService do
context 'GitLab CI is enabled' do
it "forks and enables CI for fork" do
@from_project.enable_ci
- @to_project = fork_project(@from_project, @to_user)
+ @to_project = fork_project(@from_project, @to_user, using_service: true)
expect(@to_project.builds_enabled?).to be_truthy
end
end
context "CI/CD settings" do
- let(:to_project) { fork_project(@from_project, @to_user) }
+ let(:to_project) { fork_project(@from_project, @to_user, using_service: true) }
context "when origin has git depth specified" do
before do
@@ -206,7 +206,7 @@ RSpec.describe Projects::ForkService do
end
it "creates fork with lowest level" do
- forked_project = fork_project(@from_project, @to_user)
+ forked_project = fork_project(@from_project, @to_user, using_service: true)
expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
@@ -218,7 +218,7 @@ RSpec.describe Projects::ForkService do
end
it "creates fork with private visibility levels" do
- forked_project = fork_project(@from_project, @to_user)
+ forked_project = fork_project(@from_project, @to_user, using_service: true)
expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
@@ -232,7 +232,7 @@ RSpec.describe Projects::ForkService do
end
it 'fails' do
- to_project = fork_project(@from_project, @to_user, namespace: @to_user.namespace)
+ to_project = fork_project(@from_project, @to_user, namespace: @to_user.namespace, using_service: true)
expect(to_project.errors[:forked_from_project_id]).to eq(['is forbidden'])
end
@@ -253,7 +253,7 @@ RSpec.describe Projects::ForkService do
@group.add_user(@developer, GroupMember::DEVELOPER)
@project.add_user(@developer, :developer)
@project.add_user(@group_owner, :developer)
- @opts = { namespace: @group }
+ @opts = { namespace: @group, using_service: true }
end
context 'fork project for group' do
@@ -299,7 +299,7 @@ RSpec.describe Projects::ForkService do
group_owner = create(:user)
private_group.add_owner(group_owner)
- forked_project = fork_project(public_project, group_owner, namespace: private_group)
+ forked_project = fork_project(public_project, group_owner, namespace: private_group, using_service: true)
expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
@@ -310,7 +310,7 @@ RSpec.describe Projects::ForkService do
context 'when a project is already forked' do
it 'creates a new poolresository after the project is moved to a new shard' do
project = create(:project, :public, :repository)
- fork_before_move = fork_project(project)
+ fork_before_move = fork_project(project, nil, using_service: true)
# Stub everything required to move a project to a Gitaly shard that does not exist
allow(Gitlab::GitalyClient).to receive(:filesystem_id).with('default').and_call_original
@@ -329,7 +329,7 @@ RSpec.describe Projects::ForkService do
destination_storage_name: 'test_second_storage'
)
Projects::UpdateRepositoryStorageService.new(storage_move).execute
- fork_after_move = fork_project(project.reload)
+ fork_after_move = fork_project(project.reload, nil, using_service: true)
pool_repository_before_move = PoolRepository.joins(:shard)
.find_by(source_project: project, shards: { name: 'default' })
pool_repository_after_move = PoolRepository.joins(:shard)
@@ -350,7 +350,7 @@ RSpec.describe Projects::ForkService do
context 'when no pool exists' do
it 'creates a new object pool' do
- forked_project = fork_project(fork_from_project, forker)
+ forked_project = fork_project(fork_from_project, forker, using_service: true)
expect(forked_project.pool_repository).to eq(fork_from_project.pool_repository)
end
@@ -360,7 +360,7 @@ RSpec.describe Projects::ForkService do
let!(:pool_repository) { create(:pool_repository, source_project: fork_from_project) }
it 'joins the object pool' do
- forked_project = fork_project(fork_from_project, forker)
+ forked_project = fork_project(fork_from_project, forker, using_service: true)
expect(forked_project.pool_repository).to eq(fork_from_project.pool_repository)
end
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 5e1b6f2e404..969381b8748 100644
--- a/spec/services/projects/hashed_storage/base_attachment_service_spec.rb
+++ b/spec/services/projects/hashed_storage/base_attachment_service_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe Projects::HashedStorage::BaseAttachmentService do
target_path = Dir.mktmpdir
expect(Dir.exist?(target_path)).to be_truthy
- Timecop.freeze do
+ freeze_time do
suffix = Time.current.utc.to_i
subject.send(:discard_path!, target_path)
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 a606371099d..cfe8e863223 100644
--- a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
@@ -4,7 +4,8 @@ require 'spec_helper'
RSpec.describe Projects::LfsPointers::LfsDownloadService do
include StubRequests
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
+
let(:lfs_content) { SecureRandom.random_bytes(10) }
let(:oid) { Digest::SHA256.hexdigest(lfs_content) }
let(:download_link) { "http://gitlab.com/#{oid}" }
@@ -14,9 +15,11 @@ RSpec.describe Projects::LfsPointers::LfsDownloadService do
subject { described_class.new(project, lfs_object) }
- before do
+ before_all do
ApplicationSetting.create_from_defaults
+ end
+ before do
stub_application_setting(allow_local_requests_from_web_hooks_and_services: local_request_setting)
allow(project).to receive(:lfs_enabled?).and_return(true)
end
@@ -226,5 +229,55 @@ RSpec.describe Projects::LfsPointers::LfsDownloadService do
subject.execute
end
end
+
+ context 'when a large lfs object with the same oid already exists' do
+ let!(:existing_lfs_object) { create(:lfs_object, :with_file, :correct_oid) }
+
+ before do
+ stub_const("#{described_class}::LARGE_FILE_SIZE", 500)
+ stub_full_request(download_link).to_return(body: lfs_content)
+ end
+
+ context 'and first fragments are the same' do
+ let(:lfs_content) { existing_lfs_object.file.read }
+
+ context 'when lfs_link_existing_object feature flag disabled' do
+ before do
+ stub_feature_flags(lfs_link_existing_object: false)
+ end
+
+ it 'does not call link_existing_lfs_object!' do
+ expect(subject).not_to receive(:link_existing_lfs_object!)
+
+ subject.execute
+ end
+ end
+
+ it 'returns success' do
+ expect(subject.execute).to eq({ status: :success })
+ end
+
+ it 'links existing lfs object to the project' do
+ expect { subject.execute }
+ .to change { project.lfs_objects.include?(existing_lfs_object) }.from(false).to(true)
+ end
+ end
+
+ context 'and first fragments diverges' do
+ let(:lfs_content) { SecureRandom.random_bytes(1000) }
+ let(:oid) { existing_lfs_object.oid }
+
+ it 'raises oid mismatch error' do
+ expect(subject.execute).to eq({
+ status: :error,
+ message: "LFS file with oid #{oid} cannot be linked with an existing LFS object"
+ })
+ end
+
+ it 'does not change lfs objects' do
+ expect { subject.execute }.not_to change { project.lfs_objects }
+ end
+ end
+ end
end
end
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 d59f5dbae19..0e7d16f18e8 100644
--- a/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb
@@ -24,11 +24,11 @@ RSpec.describe Projects::LfsPointers::LfsLinkService do
end
it 'links existing lfs objects to the project' do
- expect(project.all_lfs_objects.count).to eq 2
+ expect(project.lfs_objects.count).to eq 2
linked = subject.execute(new_oid_list.keys)
- expect(project.all_lfs_objects.count).to eq 3
+ expect(project.lfs_objects.count).to eq 3
expect(linked.size).to eq 3
end
@@ -52,7 +52,7 @@ RSpec.describe Projects::LfsPointers::LfsLinkService do
lfs_objects = create_list(:lfs_object, 7)
linked = subject.execute(lfs_objects.pluck(:oid))
- expect(project.all_lfs_objects.count).to eq 9
+ expect(project.lfs_objects.count).to eq 9
expect(linked.size).to eq 7
end
diff --git a/spec/services/projects/open_issues_count_service_spec.rb b/spec/services/projects/open_issues_count_service_spec.rb
index c739fea5ecf..294c9adcc92 100644
--- a/spec/services/projects/open_issues_count_service_spec.rb
+++ b/spec/services/projects/open_issues_count_service_spec.rb
@@ -10,6 +10,14 @@ RSpec.describe Projects::OpenIssuesCountService, :use_clean_rails_memory_store_c
it_behaves_like 'a counter caching service'
describe '#count' do
+ it 'does not count test cases' do
+ create(:issue, :opened, project: project)
+ create(:incident, :opened, project: project)
+ create(:quality_test_case, :opened, project: project)
+
+ expect(described_class.new(project).count).to eq(2)
+ end
+
context 'when user is nil' do
it 'does not include confidential issues in the issue count' do
create(:issue, :opened, project: project)
diff --git a/spec/services/projects/overwrite_project_service_spec.rb b/spec/services/projects/overwrite_project_service_spec.rb
index e4495da9807..a03746d0271 100644
--- a/spec/services/projects/overwrite_project_service_spec.rb
+++ b/spec/services/projects/overwrite_project_service_spec.rb
@@ -16,6 +16,8 @@ RSpec.describe Projects::OverwriteProjectService do
subject { described_class.new(project_to, user) }
before do
+ project_to.project_feature.reload
+
allow(project_to).to receive(:import_data).and_return(double(data: { 'original_path' => project_from.path }))
end
diff --git a/spec/services/projects/prometheus/alerts/notify_service_spec.rb b/spec/services/projects/prometheus/alerts/notify_service_spec.rb
index efe8e8b9243..0e5ac7c69e3 100644
--- a/spec/services/projects/prometheus/alerts/notify_service_spec.rb
+++ b/spec/services/projects/prometheus/alerts/notify_service_spec.rb
@@ -16,11 +16,6 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do
let(:subject) { service.execute(token_input) }
- before do
- # We use `let_it_be(:project)` so we make sure to clear caches
- project.clear_memoization(:licensed_feature_available)
- end
-
context 'with valid payload' do
let_it_be(:alert_firing) { create(:prometheus_alert, project: project) }
let_it_be(:alert_resolved) { create(:prometheus_alert, project: project) }
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index 3362b333c6e..a0e83fb4a21 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe Projects::TransferService do
include GitHelpers
- let(:user) { create(:user) }
- let(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
let(:project) { create(:project, :repository, :legacy_storage, namespace: user.namespace) }
subject(:execute_transfer) { described_class.new(project, user).execute(group) }
@@ -489,6 +489,29 @@ RSpec.describe Projects::TransferService do
end
end
+ context 'moving pages' do
+ let_it_be(:project) { create(:project, namespace: user.namespace) }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'schedules a job when pages are deployed' do
+ project.mark_pages_as_deployed
+
+ expect(PagesTransferWorker).to receive(:perform_async)
+ .with("move_project", [project.path, user.namespace.full_path, group.full_path])
+
+ execute_transfer
+ end
+
+ it 'does not schedule a job when no pages are deployed' do
+ expect(PagesTransferWorker).not_to receive(:perform_async)
+
+ execute_transfer
+ end
+ end
+
def rugged_config
rugged_repo(project.repository).config
end
diff --git a/spec/services/projects/unlink_fork_service_spec.rb b/spec/services/projects/unlink_fork_service_spec.rb
index 6a2c55a5e55..073e2e09397 100644
--- a/spec/services/projects/unlink_fork_service_spec.rb
+++ b/spec/services/projects/unlink_fork_service_spec.rb
@@ -58,26 +58,6 @@ RSpec.describe Projects::UnlinkForkService, :use_clean_rails_memory_store_cachin
expect(source.forks_count).to be_zero
end
- context 'when the source has LFS objects' do
- let(:lfs_object) { create(:lfs_object) }
-
- before do
- lfs_object.projects << project
- end
-
- it 'links the fork to the lfs object before unlinking' do
- subject.execute
-
- expect(lfs_object.projects).to include(forked_project)
- end
-
- it 'does not fail if the lfs objects were already linked' do
- lfs_object.projects << forked_project
-
- expect { subject.execute }.not_to raise_error
- end
- end
-
context 'when the original project was deleted' do
it 'does not fail when the original project is deleted' do
source = forked_project.forked_from_project
@@ -152,24 +132,6 @@ RSpec.describe Projects::UnlinkForkService, :use_clean_rails_memory_store_cachin
expect(project.forks_count).to be_zero
end
- context 'when given project is a fork of an unlinked parent' do
- let!(:fork_of_fork) { fork_project(forked_project, user) }
- let(:lfs_object) { create(:lfs_object) }
-
- before do
- lfs_object.projects << project
- end
-
- it 'saves lfs objects to the root project' do
- # Remove parent from network
- described_class.new(forked_project, user).execute
-
- described_class.new(fork_of_fork, user).execute
-
- expect(lfs_object.projects).to include(fork_of_fork)
- end
- end
-
context 'and is node with a parent' do
subject { described_class.new(forked_project, user) }
diff --git a/spec/services/projects/update_pages_configuration_service_spec.rb b/spec/services/projects/update_pages_configuration_service_spec.rb
index 9f7ebd40df6..294de813e02 100644
--- a/spec/services/projects/update_pages_configuration_service_spec.rb
+++ b/spec/services/projects/update_pages_configuration_service_spec.rb
@@ -48,15 +48,6 @@ RSpec.describe Projects::UpdatePagesConfigurationService do
expect(subject).to include(status: :success)
end
end
-
- context 'when an error occurs' do
- it 'returns an error object' do
- e = StandardError.new("Failure")
- allow(service).to receive(:reload_daemon).and_raise(e)
-
- expect(subject).to eq(status: :error, message: "Failure", exception: e)
- end
- end
end
context 'when pages are not deployed' do
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index 374ce4f4ce2..bfb3cbb0131 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -29,8 +29,9 @@ RSpec.describe Projects::UpdatePagesService do
context 'for new artifacts' do
context "for a valid job" do
+ let!(:artifacts_archive) { create(:ci_job_artifact, file: file, job: build) }
+
before do
- create(:ci_job_artifact, file: file, job: build)
create(:ci_job_artifact, file_type: :metadata, file_format: :gzip, file: metadata, job: build)
build.reload
@@ -49,6 +50,7 @@ RSpec.describe Projects::UpdatePagesService do
expect(project.pages_deployed?).to be_falsey
expect(execute).to eq(:success)
expect(project.pages_metadatum).to be_deployed
+ expect(project.pages_metadatum.artifacts_archive).to eq(artifacts_archive)
expect(project.pages_deployed?).to be_truthy
# Check that all expected files are extracted
diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb
index 6785b71fcc0..1de04888e0a 100644
--- a/spec/services/projects/update_remote_mirror_service_spec.rb
+++ b/spec/services/projects/update_remote_mirror_service_spec.rb
@@ -3,9 +3,10 @@
require 'spec_helper'
RSpec.describe Projects::UpdateRemoteMirrorService do
- let(:project) { create(:project, :repository) }
- let(:remote_project) { create(:forked_project_with_submodules) }
- let(:remote_mirror) { create(:remote_mirror, project: project, enabled: true) }
+ 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) }
+
let(:remote_name) { remote_mirror.remote_name }
subject(:service) { described_class.new(project, project.creator) }
@@ -79,7 +80,6 @@ RSpec.describe Projects::UpdateRemoteMirrorService do
with_them do
before do
allow(remote_mirror).to receive(:url).and_return(url)
- allow(service).to receive(:update_mirror).with(remote_mirror).and_return(true)
end
it "returns expected status" do
@@ -128,5 +128,63 @@ RSpec.describe Projects::UpdateRemoteMirrorService do
expect(remote_mirror.last_error).to include("refs/heads/develop")
end
end
+
+ context "sending lfs objects" do
+ let_it_be(:lfs_pointer) { create(:lfs_objects_project, project: project) }
+
+ before do
+ stub_lfs_setting(enabled: true)
+ end
+
+ context 'feature flag enabled' do
+ before do
+ stub_feature_flags(push_mirror_syncs_lfs: true)
+ end
+
+ it 'pushes LFS objects to a HTTP repository' do
+ expect_next_instance_of(Lfs::PushService) do |service|
+ expect(service).to receive(:execute)
+ end
+
+ execute!
+ end
+
+ it 'does nothing to an SSH repository' do
+ remote_mirror.update!(url: 'ssh://example.com')
+
+ expect_any_instance_of(Lfs::PushService).not_to receive(:execute)
+
+ execute!
+ end
+
+ it 'does nothing if LFS is disabled' do
+ expect(project).to receive(:lfs_enabled?) { false }
+
+ expect_any_instance_of(Lfs::PushService).not_to receive(:execute)
+
+ execute!
+ end
+
+ it 'does nothing if non-password auth is specified' do
+ remote_mirror.update!(auth_method: 'ssh_public_key')
+
+ expect_any_instance_of(Lfs::PushService).not_to receive(:execute)
+
+ execute!
+ end
+ end
+
+ context 'feature flag disabled' do
+ before do
+ stub_feature_flags(push_mirror_syncs_lfs: false)
+ end
+
+ it 'does nothing' do
+ expect_any_instance_of(Lfs::PushService).not_to receive(:execute)
+
+ execute!
+ end
+ end
+ end
end
end
diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb
index 4a613f42556..7832d727220 100644
--- a/spec/services/projects/update_service_spec.rb
+++ b/spec/services/projects/update_service_spec.rb
@@ -272,7 +272,7 @@ RSpec.describe Projects::UpdateService do
result = update_project(project, user, project_feature_attributes:
{ issues_access_level: ProjectFeature::PRIVATE }
- )
+ )
expect(result).to eq({ status: :success })
expect(project.project_feature.issues_access_level).to be(ProjectFeature::PRIVATE)
@@ -325,20 +325,9 @@ RSpec.describe Projects::UpdateService do
expect(project.errors.messages[:base]).to include('There is already a repository with that name on disk')
end
- it 'renames the project without upgrading it' do
- result = update_project(project, admin, path: 'new-path')
-
- expect(result).not_to include(status: :error)
- expect(project).to be_valid
- expect(project.errors).to be_empty
- expect(project.disk_path).to include('new-path')
- expect(project.reload.hashed_storage?(:repository)).to be_falsey
- end
-
context 'when hashed storage is enabled' do
before do
stub_application_setting(hashed_storage_enabled: true)
- stub_feature_flags(skip_hashed_storage_upgrade: false)
end
it 'migrates project to a hashed storage instead of renaming the repo to another legacy name' do
@@ -349,22 +338,6 @@ RSpec.describe Projects::UpdateService do
expect(project.errors).to be_empty
expect(project.reload.hashed_storage?(:repository)).to be_truthy
end
-
- context 'when skip_hashed_storage_upgrade feature flag is enabled' do
- before do
- stub_feature_flags(skip_hashed_storage_upgrade: true)
- end
-
- it 'renames the project without upgrading it' do
- result = update_project(project, admin, path: 'new-path')
-
- expect(result).not_to include(status: :error)
- expect(project).to be_valid
- expect(project.errors).to be_empty
- expect(project.disk_path).to include('new-path')
- expect(project.reload.hashed_storage?(:repository)).to be_falsey
- end
- end
end
end
@@ -412,32 +385,6 @@ RSpec.describe Projects::UpdateService do
subject
end
-
- context 'when `async_update_pages_config` is disabled' do
- before do
- stub_feature_flags(async_update_pages_config: false)
- end
-
- it 'calls Projects::UpdatePagesConfigurationService when pages are deployed' do
- project.mark_pages_as_deployed
-
- expect(Projects::UpdatePagesConfigurationService)
- .to receive(:new)
- .with(project)
- .and_call_original
-
- subject
- end
-
- it "does not update pages config when pages aren't deployed" do
- project.mark_pages_as_not_deployed
-
- expect(Projects::UpdatePagesConfigurationService)
- .not_to receive(:new)
-
- subject
- end
- end
end
context 'when updating #pages_https_only', :https_pages_enabled do
@@ -532,14 +479,14 @@ RSpec.describe Projects::UpdateService do
attributes_for(:prometheus_service,
project: project,
properties: { api_url: "http://new.prometheus.com", manual_configuration: "0" }
- )
+ )
end
let!(:prometheus_service) do
create(:prometheus_service,
project: project,
properties: { api_url: "http://old.prometheus.com", manual_configuration: "0" }
- )
+ )
end
it 'updates existing record' do
@@ -556,7 +503,7 @@ RSpec.describe Projects::UpdateService do
attributes_for(:prometheus_service,
project: project,
properties: { api_url: "http://example.prometheus.com", manual_configuration: "0" }
- )
+ )
end
it 'creates new record' do
@@ -572,7 +519,7 @@ RSpec.describe Projects::UpdateService do
attributes_for(:prometheus_service,
project: project,
properties: { api_url: nil, manual_configuration: "1" }
- )
+ )
end
it 'does not create new record' do
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index 57e32b1aea9..b970a48051f 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -1644,6 +1644,103 @@ RSpec.describe QuickActions::InterpretService do
end
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 '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
+ end
end
describe '#explain' do
diff --git a/spec/services/releases/create_service_spec.rb b/spec/services/releases/create_service_spec.rb
index ad4696b0074..90648340b66 100644
--- a/spec/services/releases/create_service_spec.rb
+++ b/spec/services/releases/create_service_spec.rb
@@ -202,7 +202,7 @@ RSpec.describe Releases::CreateService do
let(:last_release) { project.releases.last }
around do |example|
- Timecop.freeze { example.run }
+ freeze_time { example.run }
end
subject { service.execute }
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 5e3afeabee7..1b35e224e98 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
@@ -6,20 +6,23 @@ RSpec.describe ResourceEvents::SyntheticMilestoneNotesBuilderService do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, author: user) }
+ let_it_be(:milestone) { create(:milestone, project: issue.project) }
- before do
- create_list(:resource_milestone_event, 3, issue: issue)
-
- stub_feature_flags(track_resource_milestone_change_events: false)
+ 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')
+ ]
end
- context 'when resource milestone events are disabled' do
- # https://gitlab.com/gitlab-org/gitlab/-/issues/212985
- it 'still builds notes for existing resource milestone events' do
- notes = described_class.new(issue, user).execute
+ it 'builds milestone notes for resource milestone events' do
+ notes = described_class.new(issue, user).execute
- expect(notes.size).to eq(3)
- end
+ expect(notes.map(&:created_at)).to eq(events.map(&:created_at))
+ expect(notes.map(&:note)).to eq([
+ "changed milestone to %#{milestone.iid}",
+ 'removed milestone'
+ ])
end
end
end
diff --git a/spec/services/snippets/create_service_spec.rb b/spec/services/snippets/create_service_spec.rb
index 2106a9c2045..b7fb5a98d06 100644
--- a/spec/services/snippets/create_service_spec.rb
+++ b/spec/services/snippets/create_service_spec.rb
@@ -313,6 +313,7 @@ RSpec.describe Snippets::CreateService do
it_behaves_like 'creates repository and files'
it_behaves_like 'after_save callback to store_mentions', ProjectSnippet
it_behaves_like 'when snippet_actions param is present'
+ it_behaves_like 'invalid params error response'
context 'when uploaded files are passed to the service' do
let(:extra_opts) { { files: ['foo'] } }
@@ -340,6 +341,7 @@ RSpec.describe Snippets::CreateService do
it_behaves_like 'creates repository and files'
it_behaves_like 'after_save callback to store_mentions', PersonalSnippet
it_behaves_like 'when snippet_actions param is present'
+ it_behaves_like 'invalid params error response'
context 'when the snippet description contains files' do
include FileMoverHelpers
diff --git a/spec/services/snippets/update_service_spec.rb b/spec/services/snippets/update_service_spec.rb
index 638fe1948fd..641fc56294a 100644
--- a/spec/services/snippets/update_service_spec.rb
+++ b/spec/services/snippets/update_service_spec.rb
@@ -479,6 +479,22 @@ RSpec.describe Snippets::UpdateService do
expect(blob.data).to eq content
end
end
+
+ context 'when the file_path is not present' do
+ let(:snippet_actions) { [{ action: :move, previous_path: file_path }] }
+
+ it 'generates the name for the renamed file' do
+ old_blob = blob(file_path)
+
+ expect(blob('snippetfile1.txt')).to be_nil
+ expect(subject).to be_success
+
+ new_blob = blob('snippetfile1.txt')
+
+ expect(new_blob).to be_present
+ expect(new_blob.data).to eq old_blob.data
+ end
+ end
end
context 'delete action' do
@@ -682,6 +698,7 @@ RSpec.describe Snippets::UpdateService do
it_behaves_like 'when snippet_actions param is present'
it_behaves_like 'only file_name is present'
it_behaves_like 'only content is present'
+ it_behaves_like 'invalid params error response'
it_behaves_like 'snippets spam check is performed' do
before do
subject
@@ -709,6 +726,7 @@ RSpec.describe Snippets::UpdateService do
it_behaves_like 'when snippet_actions param is present'
it_behaves_like 'only file_name is present'
it_behaves_like 'only content is present'
+ it_behaves_like 'invalid params error response'
it_behaves_like 'snippets spam check is performed' do
before do
subject
diff --git a/spec/services/static_site_editor/config_service_spec.rb b/spec/services/static_site_editor/config_service_spec.rb
new file mode 100644
index 00000000000..5fff4e0af53
--- /dev/null
+++ b/spec/services/static_site_editor/config_service_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe StaticSiteEditor::ConfigService do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
+ # params
+ let(:ref) { double(:ref) }
+ let(:path) { double(:path) }
+ let(:return_url) { double(:return_url) }
+
+ # stub data
+ let(:generated_data) { { generated: true } }
+ let(:file_data) { { file: true } }
+
+ describe '#execute' do
+ subject(:execute) do
+ described_class.new(
+ container: project,
+ current_user: user,
+ params: {
+ ref: ref,
+ path: path,
+ return_url: return_url
+ }
+ ).execute
+ end
+
+ context 'when insufficient permission' do
+ it 'returns an error' do
+ expect(execute).to be_error
+ expect(execute.message).to eq('Insufficient permissions to read configuration')
+ end
+ end
+
+ context 'for developer' do
+ before do
+ project.add_developer(user)
+
+ allow_next_instance_of(Gitlab::StaticSiteEditor::Config::GeneratedConfig) do |config|
+ allow(config).to receive(:data) { generated_data }
+ end
+
+ allow_next_instance_of(Gitlab::StaticSiteEditor::Config::FileConfig) do |config|
+ allow(config).to receive(:data) { file_data }
+ end
+ end
+
+ it 'returns merged generated data and config file data' do
+ expect(execute).to be_success
+ expect(execute.payload).to eq(generated: true, file: true)
+ end
+
+ it 'returns an error if any keys would be overwritten by the merge' do
+ generated_data[:duplicate_key] = true
+ file_data[:duplicate_key] = true
+ expect(execute).to be_error
+ expect(execute.message).to match(/duplicate key.*duplicate_key.*found/i)
+ end
+ end
+ end
+end
diff --git a/spec/services/submit_usage_ping_service_spec.rb b/spec/services/submit_usage_ping_service_spec.rb
index 450af68d383..2082a163b29 100644
--- a/spec/services/submit_usage_ping_service_spec.rb
+++ b/spec/services/submit_usage_ping_service_spec.rb
@@ -68,15 +68,15 @@ RSpec.describe SubmitUsagePingService do
end
end
- shared_examples 'saves DevOps score data from the response' do
+ shared_examples 'saves DevOps report data from the response' do
it do
expect { subject.execute }
- .to change { DevOpsScore::Metric.count }
+ .to change { DevOpsReport::Metric.count }
.by(1)
- expect(DevOpsScore::Metric.last.leader_issues).to eq 10.2
- expect(DevOpsScore::Metric.last.instance_issues).to eq 3.2
- expect(DevOpsScore::Metric.last.percentage_issues).to eq 31.37
+ expect(DevOpsReport::Metric.last.leader_issues).to eq 10.2
+ expect(DevOpsReport::Metric.last.instance_issues).to eq 3.2
+ expect(DevOpsReport::Metric.last.percentage_issues).to eq 31.37
end
end
@@ -123,15 +123,15 @@ RSpec.describe SubmitUsagePingService do
stub_response(body: with_conv_index_params)
end
- it_behaves_like 'saves DevOps score data from the response'
+ it_behaves_like 'saves DevOps report data from the response'
end
- context 'when DevOps score data is passed' do
+ context 'when DevOps report data is passed' do
before do
stub_response(body: with_dev_ops_score_params)
end
- it_behaves_like 'saves DevOps score data from the response'
+ it_behaves_like 'saves DevOps report data from the response'
end
context 'with save_raw_usage_data feature enabled' do
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 969e5955609..47b8621b5c9 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -74,15 +74,37 @@ RSpec.describe SystemNoteService do
end
end
- describe '.change_milestone' do
- let(:milestone) { double }
+ describe '.relate_issue' do
+ let(:noteable_ref) { double }
+ let(:noteable) { double }
+
+ before do
+ allow(noteable).to receive(:project).and_return(double)
+ end
it 'calls IssuableService' do
expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
- expect(service).to receive(:change_milestone).with(milestone)
+ expect(service).to receive(:relate_issue).with(noteable_ref)
end
- described_class.change_milestone(noteable, project, author, milestone)
+ described_class.relate_issue(noteable, noteable_ref, double)
+ end
+ end
+
+ describe '.unrelate_issue' do
+ let(:noteable_ref) { double }
+ let(:noteable) { double }
+
+ before do
+ allow(noteable).to receive(:project).and_return(double)
+ end
+
+ it 'calls IssuableService' do
+ expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
+ expect(service).to receive(:unrelate_issue).with(noteable_ref)
+ end
+
+ described_class.unrelate_issue(noteable, noteable_ref, double)
end
end
@@ -313,6 +335,7 @@ RSpec.describe SystemNoteService do
let(:success_message) { "SUCCESS: Successfully posted to http://jira.example.net." }
before do
+ stub_jira_service_test
stub_jira_urls(jira_issue.id)
jira_service_settings
end
@@ -705,4 +728,17 @@ RSpec.describe SystemNoteService do
described_class.new_alert_issue(alert, alert.issue, author)
end
end
+
+ describe '.create_new_alert' do
+ let(:alert) { build(:alert_management_alert) }
+ let(:monitoring_tool) { 'Prometheus' }
+
+ it 'calls AlertManagementService' do
+ expect_next_instance_of(SystemNotes::AlertManagementService) do |service|
+ expect(service).to receive(:create_new_alert).with(monitoring_tool)
+ end
+
+ described_class.create_new_alert(alert, monitoring_tool)
+ end
+ end
end
diff --git a/spec/services/system_notes/alert_management_service_spec.rb b/spec/services/system_notes/alert_management_service_spec.rb
index 943d7f55af4..4ebaa54534c 100644
--- a/spec/services/system_notes/alert_management_service_spec.rb
+++ b/spec/services/system_notes/alert_management_service_spec.rb
@@ -7,6 +7,19 @@ RSpec.describe ::SystemNotes::AlertManagementService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:noteable) { create(:alert_management_alert, :with_issue, :acknowledged, project: project) }
+ describe '#create_new_alert' do
+ subject { described_class.new(noteable: noteable, project: project).create_new_alert('Some Service') }
+
+ it_behaves_like 'a system note' do
+ let(:author) { User.alert_bot }
+ let(:action) { 'new_alert_added' }
+ end
+
+ it 'has the appropriate message' do
+ expect(subject.note).to eq('logged an alert from **Some Service**')
+ end
+ end
+
describe '#change_alert_status' do
subject { described_class.new(noteable: noteable, project: project, author: author).change_alert_status(noteable) }
diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb
index 1b5b26d90da..fec2a711dc2 100644
--- a/spec/services/system_notes/issuables_service_spec.rb
+++ b/spec/services/system_notes/issuables_service_spec.rb
@@ -13,6 +13,38 @@ RSpec.describe ::SystemNotes::IssuablesService do
let(:service) { described_class.new(noteable: noteable, project: project, author: author) }
+ describe '#relate_issue' do
+ let(:noteable_ref) { create(:issue) }
+
+ subject { service.relate_issue(noteable_ref) }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'relate' }
+ end
+
+ context 'when issue marks another as related' do
+ it 'sets the note text' do
+ expect(subject.note).to eq "marked this issue as related to #{noteable_ref.to_reference(project)}"
+ end
+ end
+ end
+
+ describe '#unrelate_issue' do
+ let(:noteable_ref) { create(:issue) }
+
+ subject { service.unrelate_issue(noteable_ref) }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'unrelate' }
+ end
+
+ context 'when issue relation is removed' do
+ it 'sets the note text' do
+ expect(subject.note).to eq "removed the relation with #{noteable_ref.to_reference(project)}"
+ end
+ end
+ end
+
describe '#change_assignee' do
subject { service.change_assignee(assignee) }
@@ -96,64 +128,6 @@ RSpec.describe ::SystemNotes::IssuablesService do
end
end
- describe '#change_milestone' do
- subject { service.change_milestone(milestone) }
-
- context 'for a project milestone' do
- let(:milestone) { create(:milestone, project: project) }
-
- it_behaves_like 'a system note' do
- let(:action) { 'milestone' }
- end
-
- context 'when milestone added' do
- it 'sets the note text' do
- reference = milestone.to_reference(format: :iid)
-
- expect(subject.note).to eq "changed milestone to #{reference}"
- end
-
- it_behaves_like 'a note with overridable created_at'
- end
-
- context 'when milestone removed' do
- let(:milestone) { nil }
-
- it 'sets the note text' do
- expect(subject.note).to eq 'removed milestone'
- end
-
- it_behaves_like 'a note with overridable created_at'
- end
- end
-
- context 'for a group milestone' do
- let(:milestone) { create(:milestone, group: group) }
-
- it_behaves_like 'a system note' do
- let(:action) { 'milestone' }
- end
-
- context 'when milestone added' do
- it 'sets the note text to use the milestone name' do
- expect(subject.note).to eq "changed milestone to #{milestone.to_reference(format: :name)}"
- end
-
- it_behaves_like 'a note with overridable created_at'
- end
-
- context 'when milestone removed' do
- let(:milestone) { nil }
-
- it 'sets the note text' do
- expect(subject.note).to eq 'removed milestone'
- end
-
- it_behaves_like 'a note with overridable created_at'
- end
- end
- end
-
describe '#change_status' do
subject { service.change_status(status, source) }
diff --git a/spec/services/task_list_toggle_service_spec.rb b/spec/services/task_list_toggle_service_spec.rb
index 276f2ae435e..81f80ee926a 100644
--- a/spec/services/task_list_toggle_service_spec.rb
+++ b/spec/services/task_list_toggle_service_spec.rb
@@ -119,7 +119,7 @@ RSpec.describe TaskListToggleService do
<<-EOT.strip_heredoc
> > * [ ] Task 1
> * [x] Task 2
- EOT
+ EOT
markdown_html = parse_markdown(markdown)
toggler = described_class.new(markdown, markdown_html,
@@ -140,7 +140,7 @@ RSpec.describe TaskListToggleService do
* [ ] Task 1
* [x] Task 2
- EOT
+ EOT
markdown_html = parse_markdown(markdown)
toggler = described_class.new(markdown, markdown_html,
@@ -158,7 +158,7 @@ RSpec.describe TaskListToggleService do
<<-EOT.strip_heredoc
- - [ ] Task 1
- [x] Task 2
- EOT
+ EOT
markdown_html = parse_markdown(markdown)
toggler = described_class.new(markdown, markdown_html,
@@ -175,7 +175,7 @@ RSpec.describe TaskListToggleService do
<<-EOT.strip_heredoc
1. - [ ] Task 1
- [x] Task 2
- EOT
+ EOT
markdown_html = parse_markdown(markdown)
toggler = described_class.new(markdown, markdown_html,
diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb
index 94d4b61933d..60903f8f367 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -65,6 +65,40 @@ RSpec.describe TodoService do
end
end
+ shared_examples 'reassigned reviewable target' do
+ context 'with no existing reviewers' do
+ let(:assigned_reviewers) { [] }
+
+ it 'creates a pending todo for new reviewer' do
+ target.reviewers = [john_doe]
+ service.send(described_method, target, author)
+
+ should_create_todo(user: john_doe, target: target, action: Todo::REVIEW_REQUESTED)
+ end
+ end
+
+ context 'with an existing reviewer' do
+ let(:assigned_reviewers) { [john_doe] }
+
+ it 'does not create a todo if unassigned' do
+ target.reviewers = []
+
+ should_not_create_any_todo { service.send(described_method, target, author) }
+ end
+
+ it 'creates a todo if new reviewer is the current user' do
+ target.reviewers = [john_doe]
+ service.send(described_method, target, john_doe)
+
+ should_create_todo(user: john_doe, target: target, author: john_doe, action: Todo::REVIEW_REQUESTED)
+ end
+
+ it 'does not create a todo if already assigned' do
+ should_not_create_any_todo { service.send(described_method, target, author, [john_doe]) }
+ end
+ end
+ end
+
describe 'Issues' do
let(:issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") }
let(:addressed_issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") }
@@ -160,6 +194,19 @@ RSpec.describe TodoService do
should_create_todo(user: john_doe, target: issue)
end
end
+
+ context 'issue is an incident' do
+ let(:issue) { create(:incident, project: project, assignees: [john_doe], author: author) }
+
+ subject do
+ service.new_issue(issue, author)
+ should_create_todo(user: john_doe, target: issue, action: Todo::ASSIGNED)
+ end
+
+ it_behaves_like 'an incident management tracked event', :incident_management_incident_todo do
+ let(:current_user) { john_doe}
+ end
+ end
end
describe '#update_issue' do
@@ -605,6 +652,17 @@ RSpec.describe TodoService do
end
end
+ describe '#reassigned_reviewable' do
+ let(:described_method) { :reassigned_reviewable }
+
+ context 'reviewable is a merge request' do
+ it_behaves_like 'reassigned reviewable target' do
+ let(:assigned_reviewers) { [] }
+ let(:target) { create(:merge_request, source_project: project, author: author, reviewers: assigned_reviewers) }
+ end
+ end
+ end
+
describe 'Merge Requests' do
let(:mr_assigned) { create(:merge_request, source_project: project, author: author, assignees: [john_doe], description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") }
let(:addressed_mr_assigned) { create(:merge_request, source_project: project, author: author, assignees: [john_doe], description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") }
diff --git a/spec/services/two_factor/destroy_service_spec.rb b/spec/services/two_factor/destroy_service_spec.rb
new file mode 100644
index 00000000000..3df4d1593c6
--- /dev/null
+++ b/spec/services/two_factor/destroy_service_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe TwoFactor::DestroyService do
+ let_it_be(:current_user) { create(:user) }
+
+ subject { described_class.new(current_user, user: user).execute }
+
+ context 'disabling two-factor authentication' do
+ shared_examples_for 'does not send notification email' do
+ context 'notification', :mailer do
+ it 'does not send a notification' do
+ perform_enqueued_jobs do
+ subject
+ end
+
+ should_not_email(user)
+ end
+ end
+ end
+
+ context 'when the user does not have two-factor authentication enabled' do
+ let(:user) { current_user }
+
+ it 'returns error' do
+ expect(subject).to eq(
+ {
+ status: :error,
+ message: 'Two-factor authentication is not enabled for this user'
+ }
+ )
+ end
+
+ it_behaves_like 'does not send notification email'
+ end
+
+ context 'when the user has two-factor authentication enabled' do
+ context 'when the executor is not authorized to disable two-factor authentication' do
+ context 'disabling the two-factor authentication of another user' do
+ let(:user) { create(:user, :two_factor) }
+
+ it 'returns error' do
+ expect(subject).to eq(
+ {
+ status: :error,
+ message: 'You are not authorized to perform this action'
+ }
+ )
+ end
+
+ it 'does not disable two-factor authentication' do
+ expect { subject }.not_to change { user.reload.two_factor_enabled? }.from(true)
+ end
+
+ it_behaves_like 'does not send notification email'
+ end
+ end
+
+ context 'when the executor is authorized to disable two-factor authentication' do
+ shared_examples_for 'disables two-factor authentication' do
+ it 'returns success' do
+ expect(subject).to eq({ status: :success })
+ end
+
+ it 'disables the two-factor authentication of the user' do
+ expect { subject }.to change { user.reload.two_factor_enabled? }.from(true).to(false)
+ end
+
+ context 'notification', :mailer do
+ it 'sends a notification' do
+ perform_enqueued_jobs do
+ subject
+ end
+
+ should_email(user)
+ end
+ end
+ end
+
+ context 'disabling their own two-factor authentication' do
+ let(:current_user) { create(:user, :two_factor) }
+ let(:user) { current_user }
+
+ it_behaves_like 'disables two-factor authentication'
+ end
+
+ context 'admin disables the two-factor authentication of another user' do
+ let(:current_user) { create(:admin) }
+ let(:user) { create(:user, :two_factor) }
+
+ it_behaves_like 'disables two-factor authentication'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/users/signup_service_spec.rb b/spec/services/users/signup_service_spec.rb
index cc234309817..7169401ab34 100644
--- a/spec/services/users/signup_service_spec.rb
+++ b/spec/services/users/signup_service_spec.rb
@@ -48,12 +48,27 @@ RSpec.describe Users::SignupService do
expect(user.reload.setup_for_company).to be(false)
end
- it 'returns an error result when setup_for_company is missing' do
- result = update_user(user, setup_for_company: '')
+ context 'when on .com' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(true)
+ end
- expect(user.reload.setup_for_company).not_to be_blank
- expect(result[:status]).to eq(:error)
- expect(result[:message]).to eq("Setup for company can't be blank")
+ it 'returns an error result when setup_for_company is missing' do
+ result = update_user(user, setup_for_company: '')
+
+ expect(user.reload.setup_for_company).not_to be_blank
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq("Setup for company can't be blank")
+ end
+ end
+
+ context 'when not on .com' do
+ it 'returns success when setup_for_company is blank' do
+ result = update_user(user, setup_for_company: '')
+
+ expect(result).to eq(status: :success)
+ expect(user.reload.setup_for_company).to be(nil)
+ end
end
end
diff --git a/spec/services/webauthn/authenticate_service_spec.rb b/spec/services/webauthn/authenticate_service_spec.rb
new file mode 100644
index 00000000000..61f64f24f5e
--- /dev/null
+++ b/spec/services/webauthn/authenticate_service_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'webauthn/fake_client'
+
+RSpec.describe Webauthn::AuthenticateService do
+ let(:client) { WebAuthn::FakeClient.new(origin) }
+ let(:user) { create(:user) }
+ let(:challenge) { Base64.strict_encode64(SecureRandom.random_bytes(32)) }
+
+ let(:origin) { 'http://localhost' }
+
+ before do
+ create_result = client.create(challenge: challenge) # rubocop:disable Rails/SaveBang
+
+ webauthn_credential = WebAuthn::Credential.from_create(create_result)
+
+ registration = WebauthnRegistration.new(credential_xid: Base64.strict_encode64(webauthn_credential.raw_id),
+ public_key: webauthn_credential.public_key,
+ counter: 0,
+ name: 'name',
+ user_id: user.id)
+ registration.save!
+ end
+
+ describe '#execute' do
+ it 'returns true if the response is valid and a matching stored credential is present' do
+ get_result = client.get(challenge: challenge)
+
+ get_result['clientExtensionResults'] = {}
+ service = Webauthn::AuthenticateService.new(user, get_result.to_json, challenge)
+
+ expect(service.execute).to be_truthy
+ end
+
+ it 'returns false if the response is valid but no matching stored credential is present' do
+ other_client = WebAuthn::FakeClient.new(origin)
+ other_client.create(challenge: challenge) # rubocop:disable Rails/SaveBang
+
+ get_result = other_client.get(challenge: challenge)
+
+ get_result['clientExtensionResults'] = {}
+ service = Webauthn::AuthenticateService.new(user, get_result.to_json, challenge)
+
+ expect(service.execute).to be_falsey
+ end
+ end
+end
diff --git a/spec/services/webauthn/register_service_spec.rb b/spec/services/webauthn/register_service_spec.rb
new file mode 100644
index 00000000000..bb9fa2080d2
--- /dev/null
+++ b/spec/services/webauthn/register_service_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'webauthn/fake_client'
+
+RSpec.describe Webauthn::RegisterService do
+ let(:client) { WebAuthn::FakeClient.new(origin) }
+ let(:user) { create(:user) }
+ let(:challenge) { Base64.strict_encode64(SecureRandom.random_bytes(32)) }
+
+ let(:origin) { 'http://localhost' }
+
+ describe '#execute' do
+ it 'returns a registration if challenge matches' do
+ create_result = client.create(challenge: challenge) # rubocop:disable Rails/SaveBang
+ webauthn_credential = WebAuthn::Credential.from_create(create_result)
+
+ params = { device_response: create_result.to_json, name: 'abc' }
+ service = Webauthn::RegisterService.new(user, params, challenge)
+
+ registration = service.execute
+ expect(registration.credential_xid).to eq(Base64.strict_encode64(webauthn_credential.raw_id))
+ expect(registration.errors.size).to eq(0)
+ end
+
+ it 'returns an error if challenge does not match' do
+ create_result = client.create(challenge: Base64.strict_encode64(SecureRandom.random_bytes(16))) # rubocop:disable Rails/SaveBang
+
+ params = { device_response: create_result.to_json, name: 'abc' }
+ service = Webauthn::RegisterService.new(user, params, challenge)
+
+ registration = service.execute
+ expect(registration.errors.size).to eq(1)
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 68beef40c0b..11a83bd9501 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -15,6 +15,7 @@ require 'rspec/retry'
require 'rspec-parameterized'
require 'shoulda/matchers'
require 'test_prof/recipes/rspec/let_it_be'
+require 'test_prof/factory_default'
rspec_profiling_is_configured =
ENV['RSPEC_PROFILING_POSTGRES_URL'].present? ||
@@ -46,10 +47,10 @@ require_relative('../ee/spec/spec_helper') if Gitlab.ee?
require Rails.root.join("spec/support/helpers/git_helpers.rb")
# Then the rest
-Dir[Rails.root.join("spec/support/helpers/*.rb")].each { |f| require f }
-Dir[Rails.root.join("spec/support/shared_contexts/*.rb")].each { |f| require f }
-Dir[Rails.root.join("spec/support/shared_examples/*.rb")].each { |f| require f }
-Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
+Dir[Rails.root.join("spec/support/helpers/*.rb")].sort.each { |f| require f }
+Dir[Rails.root.join("spec/support/shared_contexts/*.rb")].sort.each { |f| require f }
+Dir[Rails.root.join("spec/support/shared_examples/*.rb")].sort.each { |f| require f }
+Dir[Rails.root.join("spec/support/**/*.rb")].sort.each { |f| require f }
quality_level = Quality::TestLevel.new
@@ -99,6 +100,10 @@ RSpec.configure do |config|
metadata[:enable_admin_mode] = true if location =~ %r{(ee)?/spec/controllers/admin/}
end
+ config.define_derived_metadata(file_path: %r{(ee)?/spec/.+_docs\.rb\z}) do |metadata|
+ metadata[:type] = :feature
+ end
+
config.include LicenseHelpers
config.include ActiveJob::TestHelper
config.include ActiveSupport::Testing::TimeHelpers
@@ -110,6 +115,8 @@ RSpec.configure do |config|
config.include StubExperiments
config.include StubGitlabCalls
config.include StubGitlabData
+ config.include SnowplowHelpers
+ config.include NextFoundInstanceOf
config.include NextInstanceOf
config.include TestEnv
config.include Devise::Test::ControllerHelpers, type: :controller
@@ -193,6 +200,14 @@ RSpec.configure do |config|
stub_feature_flags(vue_issuable_sidebar: false)
stub_feature_flags(vue_issuable_epic_sidebar: false)
+ # The following can be removed once we are confident the
+ # unified diff lines works as expected
+ stub_feature_flags(unified_diff_lines: false)
+
+ # Merge request widget GraphQL requests are disabled in the tests
+ # for now whilst we migrate as much as we can over the GraphQL
+ stub_feature_flags(merge_request_widget_graphql: false)
+
enable_rugged = example.metadata[:enable_rugged].present?
# Disable Rugged features by default
@@ -261,6 +276,7 @@ RSpec.configure do |config|
./spec/support/protected_tags
./spec/support/shared_examples/features
./spec/support/shared_examples/requests
+ ./spec/support/shared_examples/lib/gitlab
./spec/views
./spec/workers
)
@@ -359,3 +375,6 @@ Rugged::Settings['search_path_global'] = Rails.root.join('tmp/tests').to_s
# Disable timestamp checks for invisible_captcha
InvisibleCaptcha.timestamp_enabled = false
+
+# Initialize FactoryDefault to use create_default helper
+TestProf::FactoryDefault.init
diff --git a/spec/support/factory_default.rb b/spec/support/factory_default.rb
new file mode 100644
index 00000000000..e116c28f132
--- /dev/null
+++ b/spec/support/factory_default.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+RSpec.configure do |config|
+ config.after do |ex|
+ TestProf::FactoryDefault.reset unless ex.metadata[:factory_default] == :keep
+ end
+
+ config.after(:all) do
+ TestProf::FactoryDefault.reset
+ end
+end
diff --git a/spec/support/forgery_protection.rb b/spec/support/forgery_protection.rb
index 1d6ea013292..d12e99b17c4 100644
--- a/spec/support/forgery_protection.rb
+++ b/spec/support/forgery_protection.rb
@@ -8,7 +8,7 @@ module ForgeryProtection
ActionController::Base.allow_forgery_protection = false
end
- module_function :with_forgery_protection
+ module_function :with_forgery_protection # rubocop: disable Style/AccessModifierDeclarations
end
RSpec.configure do |config|
diff --git a/spec/support/gitlab_stubs/gitlab_ci_for_sast.yml b/spec/support/gitlab_stubs/gitlab_ci_for_sast.yml
index 4134660e4b9..c4f3c3aace2 100644
--- a/spec/support/gitlab_stubs/gitlab_ci_for_sast.yml
+++ b/spec/support/gitlab_stubs/gitlab_ci_for_sast.yml
@@ -4,6 +4,7 @@ include:
variables:
SECURE_ANALYZERS_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers2"
SAST_EXCLUDED_PATHS: "spec, executables"
+ SAST_DEFAULT_ANALYZERS: "bandit, gosec"
stages:
- our_custom_security_stage
@@ -11,3 +12,4 @@ sast:
stage: our_custom_security_stage
variables:
SEARCH_MAX_DEPTH: 8
+ SAST_BRAKEMAN_LEVEL: 2
diff --git a/spec/support/helpers/ci/source_pipeline_helpers.rb b/spec/support/helpers/ci/source_pipeline_helpers.rb
new file mode 100644
index 00000000000..b99f499cc16
--- /dev/null
+++ b/spec/support/helpers/ci/source_pipeline_helpers.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+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)
+ end
+ end
+end
diff --git a/spec/support/helpers/dns_helpers.rb b/spec/support/helpers/dns_helpers.rb
index 29be4da6902..1795b0a9ac3 100644
--- a/spec/support/helpers/dns_helpers.rb
+++ b/spec/support/helpers/dns_helpers.rb
@@ -25,6 +25,6 @@ module DnsHelpers
def permit_local_dns!
local_addresses = /\A(127|10)\.0\.0\.\d{1,3}|(192\.168|172\.16)\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|localhost\z/i
allow(Addrinfo).to receive(:getaddrinfo).with(local_addresses, anything, nil, :STREAM).and_call_original
- allow(Addrinfo).to receive(:getaddrinfo).with(local_addresses, anything, nil, :STREAM, anything, anything).and_call_original
+ allow(Addrinfo).to receive(:getaddrinfo).with(local_addresses, anything, nil, :STREAM, anything, anything, any_args).and_call_original
end
end
diff --git a/spec/support/helpers/docs_screenshot_helpers.rb b/spec/support/helpers/docs_screenshot_helpers.rb
new file mode 100644
index 00000000000..aa3aad0a740
--- /dev/null
+++ b/spec/support/helpers/docs_screenshot_helpers.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'fileutils'
+require 'mini_magick'
+
+module DocsScreenshotHelpers
+ extend ActiveSupport::Concern
+
+ def set_crop_data(element, padding)
+ @crop_element = element
+ @crop_padding = padding
+ end
+
+ def crop_image_screenshot(path)
+ element_rect = @crop_element.evaluate_script("this.getBoundingClientRect()")
+
+ width = element_rect['width'] + (@crop_padding * 2)
+ height = element_rect['height'] + (@crop_padding * 2)
+
+ x = element_rect['x'] - @crop_padding
+ y = element_rect['y'] - @crop_padding
+
+ image = MiniMagick::Image.new(path)
+ image.crop "#{width}x#{height}+#{x}+#{y}"
+ end
+
+ included do |base|
+ after do |example|
+ filename = "#{example.description}.png"
+ path = File.expand_path(filename, 'doc/')
+ page.save_screenshot(path)
+
+ if @crop_element
+ crop_image_screenshot(path)
+ set_crop_data(nil, nil)
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/fake_u2f_device.rb b/spec/support/helpers/fake_u2f_device.rb
index f765b277175..2ed1222ebd3 100644
--- a/spec/support/helpers/fake_u2f_device.rb
+++ b/spec/support/helpers/fake_u2f_device.rb
@@ -3,9 +3,10 @@
class FakeU2fDevice
attr_reader :name
- def initialize(page, name)
+ def initialize(page, name, device = nil)
@page = page
@name = name
+ @u2f_device = device
end
def respond_to_u2f_registration
diff --git a/spec/support/helpers/fake_webauthn_device.rb b/spec/support/helpers/fake_webauthn_device.rb
new file mode 100644
index 00000000000..d2c2f7d6bf3
--- /dev/null
+++ b/spec/support/helpers/fake_webauthn_device.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+require 'webauthn/fake_client'
+
+class FakeWebauthnDevice
+ attr_reader :name
+
+ def initialize(page, name, device = nil)
+ @page = page
+ @name = name
+ @webauthn_device = device
+ end
+
+ def respond_to_webauthn_registration
+ app_id = @page.evaluate_script('gon.webauthn.app_id')
+ challenge = @page.evaluate_script('gon.webauthn.options.challenge')
+
+ json_response = webauthn_device(app_id).create(challenge: challenge).to_json # rubocop:disable Rails/SaveBang
+ @page.execute_script <<~JS
+ var result = #{json_response};
+ result.getClientExtensionResults = () => ({});
+ navigator.credentials.create = function(_) {
+ return Promise.resolve(result);
+ };
+ JS
+ end
+
+ def respond_to_webauthn_authentication
+ app_id = @page.evaluate_script('JSON.parse(gon.webauthn.options).extensions.appid')
+ challenge = @page.evaluate_script('JSON.parse(gon.webauthn.options).challenge')
+
+ begin
+ json_response = webauthn_device(app_id).get(challenge: challenge).to_json
+
+ rescue RuntimeError
+ # A runtime error is raised from fake webauthn if no credentials have been registered yet.
+ # To be able to test non registered devices, credentials are created ad-hoc
+ webauthn_device(app_id).create # rubocop:disable Rails/SaveBang
+ json_response = webauthn_device(app_id).get(challenge: challenge).to_json
+ end
+
+ @page.execute_script <<~JS
+ var result = #{json_response};
+ result.getClientExtensionResults = () => ({});
+ navigator.credentials.get = function(_) {
+ return Promise.resolve(result);
+ };
+ JS
+ @page.click_link('Try again?', href: false)
+ end
+
+ def fake_webauthn_authentication
+ @page.execute_script <<~JS
+ const mockResponse = {
+ type: 'public-key',
+ id: '',
+ rawId: '',
+ response: { clientDataJSON: '', authenticatorData: '', signature: '', userHandle: '' },
+ getClientExtensionResults: () => {},
+ };
+ window.gl.resolveWebauthn(mockResponse);
+ JS
+ end
+
+ def add_credential(app_id, credential_id, credential_key)
+ credentials = { URI.parse(app_id).host => { credential_id => { credential_key: credential_key, sign_count: 0 } } }
+ webauthn_device(app_id).send(:authenticator).instance_variable_set(:@credentials, credentials)
+ end
+
+ private
+
+ def webauthn_device(app_id)
+ @webauthn_device ||= WebAuthn::FakeClient.new(app_id)
+ end
+end
diff --git a/spec/support/helpers/feature_flag_helpers.rb b/spec/support/helpers/feature_flag_helpers.rb
new file mode 100644
index 00000000000..93cd915879b
--- /dev/null
+++ b/spec/support/helpers/feature_flag_helpers.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module FeatureFlagHelpers
+ def create_flag(project, name, active = true, description: nil, version: Operations::FeatureFlag.versions['legacy_flag'])
+ 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,
+ feature_flag: feature_flag,
+ environment_scope: environment_scope,
+ active: active,
+ strategies: strategies)
+ end
+
+ def within_feature_flag_row(index)
+ within ".gl-responsive-table-row:nth-child(#{index + 1})" do
+ yield
+ end
+ end
+
+ def within_feature_flag_scopes
+ within '.js-feature-flag-environments' do
+ yield
+ end
+ end
+
+ def within_scope_row(index)
+ within ".gl-responsive-table-row:nth-child(#{index + 1})" do
+ yield
+ end
+ end
+
+ def within_strategy_row(index)
+ within ".feature-flags-form > fieldset > div[data-testid='feature-flag-strategies'] > div:nth-child(#{index})" do
+ yield
+ end
+ end
+
+ def within_environment_spec
+ within '.table-section:nth-child(1)' do
+ yield
+ end
+ end
+
+ def within_status
+ within '.table-section:nth-child(2)' do
+ yield
+ end
+ end
+
+ def within_delete
+ within '.table-section:nth-child(4)' do
+ yield
+ end
+ end
+
+ def edit_feature_flag_button
+ find('.js-feature-flag-edit-button')
+ end
+
+ def delete_strategy_button
+ find("button[data-testid='delete-strategy-button']")
+ end
+
+ def add_linked_issue_button
+ find('.js-issue-count-badge-add-button')
+ end
+
+ def remove_linked_issue_button
+ find('.js-issue-item-remove-button')
+ end
+
+ def status_toggle_button
+ find('[data-testid="feature-flag-status-toggle"] button')
+ end
+
+ def expect_status_toggle_button_to_be_checked
+ expect(page).to have_css('[data-testid="feature-flag-status-toggle"] button.is-checked')
+ end
+
+ def expect_status_toggle_button_not_to_be_checked
+ expect(page).to have_css('[data-testid="feature-flag-status-toggle"] button:not(.is-checked)')
+ end
+
+ def expect_status_toggle_button_to_be_disabled
+ expect(page).to have_css('[data-testid="feature-flag-status-toggle"] button.is-disabled')
+ end
+
+ def expect_user_to_see_feature_flags_index_page
+ expect(page).to have_text('Feature Flags')
+ expect(page).to have_text('Lists')
+ end
+end
diff --git a/spec/support/helpers/features/editor_lite_spec_helpers.rb b/spec/support/helpers/features/editor_lite_spec_helpers.rb
new file mode 100644
index 00000000000..0a67e753379
--- /dev/null
+++ b/spec/support/helpers/features/editor_lite_spec_helpers.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# These helpers help you interact within the Editor Lite (single-file editor, snippets, etc.).
+#
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module EditorLiteSpecHelpers
+ include ActionView::Helpers::JavaScriptHelper
+
+ def editor_set_value(value)
+ editor = find('.monaco-editor')
+ uri = editor['data-uri']
+
+ execute_script("monaco.editor.getModel('#{uri}').setValue('#{escape_javascript(value)}')")
+ end
+
+ def editor_get_value
+ editor = find('.monaco-editor')
+ uri = editor['data-uri']
+
+ evaluate_script("monaco.editor.getModel('#{uri}').getValue()")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/features/releases_helpers.rb b/spec/support/helpers/features/releases_helpers.rb
new file mode 100644
index 00000000000..0d46918b05c
--- /dev/null
+++ b/spec/support/helpers/features/releases_helpers.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+# These helpers fill fields on the "New Release" and
+# "Edit Release" pages. They use the keyboard to navigate
+# from one field to the next and assume that when
+# they are called, the field to be filled out is already focused.
+#
+# Usage:
+# describe "..." do
+# include Spec::Support::Helpers::Features::ReleasesHelpers
+# ...
+#
+# fill_tag_name("v1.0")
+# select_create_from("my-feature-branch")
+#
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module ReleasesHelpers
+ # Returns the element that currently has keyboard focus.
+ # Reminder that this returns a Selenium::WebDriver::Element
+ # _not_ a Capybara::Node::Element
+ def focused_element
+ page.driver.browser.switch_to.active_element
+ end
+
+ def fill_tag_name(tag_name, and_tab: true)
+ expect(focused_element).to eq(find_field('Tag name').native)
+
+ focused_element.send_keys(tag_name)
+
+ focused_element.send_keys(:tab) if and_tab
+ end
+
+ def select_create_from(branch_name, and_tab: true)
+ expect(focused_element).to eq(find('[data-testid="create-from-field"] button').native)
+
+ focused_element.send_keys(:enter)
+
+ # Wait for the dropdown to be rendered
+ page.find('.ref-selector .dropdown-menu')
+
+ # Pressing Enter in the search box shouldn't submit the form
+ focused_element.send_keys(branch_name, :enter)
+
+ # Wait for the search to return
+ page.find('.ref-selector .dropdown-item', text: branch_name, match: :first)
+
+ focused_element.send_keys(:arrow_down, :enter)
+
+ focused_element.send_keys(:tab) if and_tab
+ end
+
+ def fill_release_title(release_title, and_tab: true)
+ expect(focused_element).to eq(find_field('Release title').native)
+
+ focused_element.send_keys(release_title)
+
+ focused_element.send_keys(:tab) if and_tab
+ end
+
+ def select_milestone(milestone_title, and_tab: true)
+ expect(focused_element).to eq(find('[data-testid="milestones-field"] button').native)
+
+ focused_element.send_keys(:enter)
+
+ # Wait for the dropdown to be rendered
+ page.find('.project-milestone-combobox .dropdown-menu')
+
+ # Clear any existing input
+ focused_element.attribute('value').length.times { focused_element.send_keys(:backspace) }
+
+ # Pressing Enter in the search box shouldn't submit the form
+ focused_element.send_keys(milestone_title, :enter)
+
+ # Wait for the search to return
+ page.find('.project-milestone-combobox .dropdown-item', text: milestone_title, match: :first)
+
+ focused_element.send_keys(:arrow_down, :arrow_down, :enter)
+
+ focused_element.send_keys(:tab) if and_tab
+ end
+
+ def fill_release_notes(release_notes, and_tab: true)
+ expect(focused_element).to eq(find_field('Release notes').native)
+
+ focused_element.send_keys(release_notes)
+
+ # Tab past the links at the bottom of the editor
+ focused_element.send_keys(:tab, :tab, :tab) if and_tab
+ end
+
+ def fill_asset_link(link, and_tab: true)
+ expect(focused_element['id']).to start_with('asset-url-')
+
+ focused_element.send_keys(link[:url], :tab, link[:title], :tab, link[:type])
+
+ # Tab past the "Remove asset link" button
+ focused_element.send_keys(:tab, :tab) if and_tab
+ end
+
+ # Click "Add another link" and tab back to the beginning of the new row
+ def add_another_asset_link
+ expect(focused_element).to eq(find_button('Add another link').native)
+
+ focused_element.send_keys(:enter,
+ [:shift, :tab],
+ [:shift, :tab],
+ [:shift, :tab],
+ [:shift, :tab])
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/features/snippet_helpers.rb b/spec/support/helpers/features/snippet_helpers.rb
new file mode 100644
index 00000000000..c01d179770c
--- /dev/null
+++ b/spec/support/helpers/features/snippet_helpers.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+# These helpers help you interact within the Editor Lite (single-file editor, snippets, etc.).
+#
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module SnippetSpecHelpers
+ include ActionView::Helpers::JavaScriptHelper
+ include Spec::Support::Helpers::Features::EditorLiteSpecHelpers
+
+ def snippet_get_first_blob_path
+ page.find_field(snippet_blob_path_field, match: :first).value
+ end
+
+ def snippet_get_first_blob_value
+ page.find(snippet_blob_content_selector, match: :first)
+ end
+
+ def snippet_description_value
+ page.find_field(snippet_description_field).value
+ end
+
+ def snippet_fill_in_form(title:, content:, description: '')
+ # fill_in snippet_title_field, with: title
+ # editor_set_value(content)
+ fill_in snippet_title_field, with: title
+
+ if description
+ # Click placeholder first to expand full description field
+ description_field.click
+ fill_in snippet_description_field, with: description
+ end
+
+ page.within('.file-editor') do
+ el = find('.inputarea')
+ el.send_keys content
+ end
+ end
+
+ private
+
+ def description_field
+ find('.js-description-input').find('input,textarea')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/features/two_factor_helpers.rb b/spec/support/helpers/features/two_factor_helpers.rb
new file mode 100644
index 00000000000..08a7665201f
--- /dev/null
+++ b/spec/support/helpers/features/two_factor_helpers.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+# These helpers allow you to manage and register
+# U2F and WebAuthn devices
+#
+# Usage:
+# describe "..." do
+# include Spec::Support::Helpers::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
+
+ # 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
+
+ # 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
+ end
+ end
+end
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index 87525734490..5635ba3df05 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -241,6 +241,39 @@ module GraphqlHelpers
post_graphql(mutation.query, current_user: current_user, variables: mutation.variables)
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'
+ )
+ end
+
+ def file_paths_in_mutation(mutation)
+ paths = []
+ find_uploads(paths, [], mutation.variables)
+
+ paths
+ end
+
+ # Depth first search for UploadedFile values
+ def find_uploads(paths, path, value)
+ case value
+ when Rack::Test::UploadedFile
+ paths << path
+ when Hash
+ value.each do |k, v|
+ find_uploads(paths, path + [k], v)
+ end
+ when Array
+ value.each_with_index do |v, i|
+ find_uploads(paths, path + [i], v)
+ end
+ end
+ end
+
# this implements GraphQL multipart request v2
# https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2
# this is simplified and do not support file deduplication
diff --git a/spec/support/helpers/jira_service_helper.rb b/spec/support/helpers/jira_service_helper.rb
index 4895bc3ba15..698490c8c92 100644
--- a/spec/support/helpers/jira_service_helper.rb
+++ b/spec/support/helpers/jira_service_helper.rb
@@ -78,8 +78,7 @@ module JiraServiceHelper
end
def stub_jira_service_test
- WebMock.stub_request(:get, 'https://jira.example.com/rest/api/2/serverInfo')
- .to_return(body: { url: 'http://url' }.to_json)
+ WebMock.stub_request(:get, /serverInfo/).to_return(body: { url: 'http://url' }.to_json)
end
def stub_jira_urls(issue_id)
diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb
index 1118cfcf7ac..e21d4497cda 100644
--- a/spec/support/helpers/login_helpers.rb
+++ b/spec/support/helpers/login_helpers.rb
@@ -111,6 +111,11 @@ module LoginHelpers
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
+ end
+
def mock_auth_hash_with_saml_xml(provider, uid, email, saml_response)
response_object = { document: saml_xml(saml_response) }
mock_auth_hash(provider, uid, email, response_object: response_object)
diff --git a/spec/support/helpers/markdown_feature.rb b/spec/support/helpers/markdown_feature.rb
index 40e0d4413e2..0cb2863dc2c 100644
--- a/spec/support/helpers/markdown_feature.rb
+++ b/spec/support/helpers/markdown_feature.rb
@@ -87,6 +87,10 @@ class MarkdownFeature
@group_milestone ||= create(:milestone, name: 'group-milestone', group: group)
end
+ def alert
+ @alert ||= create(:alert_management_alert, project: project)
+ end
+
# Cross-references -----------------------------------------------------------
def xproject
@@ -125,6 +129,10 @@ class MarkdownFeature
@xmilestone ||= create(:milestone, project: xproject)
end
+ def xalert
+ @xalert ||= create(:alert_management_alert, project: xproject)
+ end
+
def urls
Gitlab::Routing.url_helpers
end
diff --git a/spec/support/helpers/metrics_dashboard_helpers.rb b/spec/support/helpers/metrics_dashboard_helpers.rb
index 7168079fead..a384e44f428 100644
--- a/spec/support/helpers/metrics_dashboard_helpers.rb
+++ b/spec/support/helpers/metrics_dashboard_helpers.rb
@@ -47,7 +47,7 @@ module MetricsDashboardHelpers
end
def business_metric_title
- PrometheusMetricEnums.group_details[:business][:group_title]
+ Enums::PrometheusMetric.group_details[:business][:group_title]
end
def self_monitoring_dashboard_path
diff --git a/spec/support/helpers/multipart_helpers.rb b/spec/support/helpers/multipart_helpers.rb
new file mode 100644
index 00000000000..043cb6e1420
--- /dev/null
+++ b/spec/support/helpers/multipart_helpers.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module MultipartHelpers
+ include WorkhorseHelpers
+
+ def post_env(rewritten_fields:, params:, secret:, issuer:)
+ token = JWT.encode({ 'iss' => issuer, 'rewritten_fields' => rewritten_fields }, secret, 'HS256')
+ Rack::MockRequest.env_for(
+ '/',
+ method: 'post',
+ params: params,
+ described_class::RACK_ENV_KEY => token
+ )
+ end
+
+ # This function assumes a `mode` variable to be set
+ def upload_parameters_for(filepath: nil, key: nil, filename: 'filename', remote_id: 'remote_id')
+ result = {
+ "#{key}.name" => filename,
+ "#{key}.type" => "application/octet-stream",
+ "#{key}.sha256" => "1234567890"
+ }
+
+ case mode
+ when :local
+ result["#{key}.path"] = filepath
+ when :remote
+ result["#{key}.remote_id"] = remote_id
+ result["#{key}.size"] = 3.megabytes
+ else
+ raise ArgumentError, "can't handle #{mode} mode"
+ end
+
+ return result if ::Feature.disabled?(:upload_middleware_jwt_params_handler)
+
+ # the HandlerForJWTParams expects a jwt token with the upload parameters
+ # *without* the "#{key}." prefix
+ result.deep_transform_keys! { |k| k.remove("#{key}.") }
+ {
+ "#{key}.gitlab-workhorse-upload" => jwt_token('upload' => result)
+ }
+ end
+
+ # This function assumes a `mode` variable to be set
+ def rewritten_fields_hash(hash)
+ if mode == :remote
+ # For remote uploads, workhorse still submits rewritten_fields,
+ # but all the values are empty strings.
+ hash.keys.each { |k| hash[k] = '' }
+ end
+
+ hash
+ end
+
+ def expect_uploaded_files(uploaded_file_expectations)
+ expect(app).to receive(:call) do |env|
+ Array.wrap(uploaded_file_expectations).each do |expectation|
+ file = get_params(env).dig(*expectation[:params_path])
+ expect_uploaded_file(file, expectation)
+ end
+ end
+ end
+
+ # This function assumes a `mode` variable to be set
+ def expect_uploaded_file(file, expectation)
+ expect(file).to be_a(::UploadedFile)
+ expect(file.original_filename).to eq(expectation[:original_filename])
+ expect(file.sha256).to eq('1234567890')
+
+ case mode
+ when :local
+ expect(file.path).to eq(File.realpath(expectation[:filepath]))
+ expect(file.remote_id).to be_nil
+ expect(file.size).to eq(expectation[:size])
+ when :remote
+ expect(file.remote_id).to eq(expectation[:remote_id])
+ expect(file.path).to be_nil
+ expect(file.size).to eq(3.megabytes)
+ else
+ raise ArgumentError, "can't handle #{mode} mode"
+ end
+ end
+
+ # Rails doesn't combine the GET/POST parameters in
+ # ActionDispatch::HTTP::Parameters if action_dispatch.request.parameters is set:
+ # https://github.com/rails/rails/blob/aea6423f013ca48f7704c70deadf2cd6ac7d70a1/actionpack/lib/action_dispatch/http/parameters.rb#L41
+ def get_params(env)
+ req = ActionDispatch::Request.new(env)
+ req.GET.merge(req.POST)
+ end
+end
diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb
index c7aa2ffe536..11e67ba394c 100644
--- a/spec/support/helpers/navbar_structure_helper.rb
+++ b/spec/support/helpers/navbar_structure_helper.rb
@@ -4,13 +4,13 @@ module NavbarStructureHelper
def insert_after_nav_item(before_nav_item_name, new_nav_item:)
expect(structure).to include(a_hash_including(nav_item: before_nav_item_name))
- index = structure.find_index { |h| h[:nav_item] == before_nav_item_name }
+ index = structure.find_index { |h| h[:nav_item] == before_nav_item_name if h }
structure.insert(index + 1, new_nav_item)
end
def insert_after_sub_nav_item(before_sub_nav_item_name, within:, new_sub_nav_item_name:)
expect(structure).to include(a_hash_including(nav_item: within))
- hash = structure.find { |h| h[:nav_item] == within }
+ hash = structure.find { |h| h[:nav_item] == within if h }
expect(hash).to have_key(:nav_sub_items)
expect(hash[:nav_sub_items]).to include(before_sub_nav_item_name)
diff --git a/spec/support/helpers/next_found_instance_of.rb b/spec/support/helpers/next_found_instance_of.rb
new file mode 100644
index 00000000000..ff34fcdd1d3
--- /dev/null
+++ b/spec/support/helpers/next_found_instance_of.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module NextFoundInstanceOf
+ ERROR_MESSAGE = 'NextFoundInstanceOf mock helpers can only be used with ActiveRecord targets'
+
+ def expect_next_found_instance_of(klass)
+ check_if_active_record!(klass)
+
+ stub_allocate(expect(klass)) do |expectation|
+ yield(expectation)
+ end
+ end
+
+ def allow_next_found_instance_of(klass)
+ check_if_active_record!(klass)
+
+ stub_allocate(allow(klass)) do |allowance|
+ yield(allowance)
+ end
+ end
+
+ private
+
+ def check_if_active_record!(klass)
+ raise ArgumentError.new(ERROR_MESSAGE) unless klass < ActiveRecord::Base
+ end
+
+ def stub_allocate(target)
+ target.to receive(:allocate).and_wrap_original do |method|
+ method.call.tap { |allocation| yield(allocation) }
+ end
+ end
+end
diff --git a/spec/support/helpers/project_forks_helper.rb b/spec/support/helpers/project_forks_helper.rb
index a32e39e52c8..4b4285f251e 100644
--- a/spec/support/helpers/project_forks_helper.rb
+++ b/spec/support/helpers/project_forks_helper.rb
@@ -17,14 +17,26 @@ module ProjectForksHelper
project.add_developer(user)
end
- unless params[:namespace] || params[:namespace_id]
+ unless params[:namespace]
params[:namespace] = create(:group)
params[:namespace].add_owner(user)
end
+ namespace = params[:namespace]
+ create_repository = params.delete(:repository)
+
+ unless params[:target_project] || params[:using_service]
+ target_level = [project.visibility_level, namespace.visibility_level].min
+ visibility_level = Gitlab::VisibilityLevel.closest_allowed_level(target_level)
+
+ params[:target_project] =
+ create(:project,
+ (:repository if create_repository),
+ visibility_level: visibility_level, creator: user, namespace: namespace)
+ end
+
service = Projects::ForkService.new(project, user, params)
- create_repository = params.delete(:repository)
# Avoid creating a repository
unless create_repository
allow(RepositoryForkWorker).to receive(:perform_async).and_return(true)
diff --git a/spec/support/helpers/repo_helpers.rb b/spec/support/helpers/repo_helpers.rb
index 7741c805b37..bbba58d60d6 100644
--- a/spec/support/helpers/repo_helpers.rb
+++ b/spec/support/helpers/repo_helpers.rb
@@ -125,7 +125,7 @@ eos
end
def create_file_in_repo(
- project, start_branch, branch_name, filename, content,
+ project, start_branch, branch_name, filename, content,
commit_message: 'Add new content')
Files::CreateService.new(
project,
diff --git a/spec/support/helpers/snowplow_helpers.rb b/spec/support/helpers/snowplow_helpers.rb
new file mode 100644
index 00000000000..83a5b7e48bc
--- /dev/null
+++ b/spec/support/helpers/snowplow_helpers.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module SnowplowHelpers
+ # Asserts call for one snowplow event from `Gitlab::Tracking#event`.
+ #
+ # @param [Hash]
+ #
+ # Examples:
+ #
+ # describe '#show', :snowplow do
+ # it 'tracks snowplow events' do
+ # get :show
+ #
+ # expect_snowplow_event(category: 'Experiment', action: 'start')
+ # end
+ # end
+ #
+ # describe '#create', :snowplow do
+ # it 'tracks snowplow events' do
+ # post :create
+ #
+ # expect_snowplow_event(
+ # category: 'Experiment',
+ # action: 'created',
+ # )
+ # expect_snowplow_event(
+ # category: 'Experiment',
+ # action: 'accepted',
+ # property: 'property',
+ # label: 'label'
+ # )
+ # end
+ # end
+ def expect_snowplow_event(category:, action:, **kwargs)
+ expect(Gitlab::Tracking).to have_received(:event)
+ .with(category, action, **kwargs).at_least(:once)
+ end
+
+ # Asserts that no call to `Gitlab::Tracking#event` was made.
+ #
+ # Example:
+ #
+ # describe '#show', :snowplow do
+ # it 'does not track any snowplow events' do
+ # get :show
+ #
+ # expect_no_snowplow_event
+ # end
+ # end
+ def expect_no_snowplow_event
+ expect(Gitlab::Tracking).not_to have_received(:event)
+ end
+end
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index 8a52a614821..476b7d34ee5 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -14,7 +14,7 @@ module StubObjectStorage
end
def stub_object_storage_uploader(
- config:,
+ config:,
uploader:,
remote_directory:,
enabled: true,
diff --git a/spec/support/helpers/stubbed_feature.rb b/spec/support/helpers/stubbed_feature.rb
index e78efcf6b75..d4e9af7a031 100644
--- a/spec/support/helpers/stubbed_feature.rb
+++ b/spec/support/helpers/stubbed_feature.rb
@@ -37,10 +37,7 @@ module StubbedFeature
# We do `m.call` as we want to validate the execution of method arguments
# and a feature flag state if it is not persisted
unless Feature.persisted_name?(args.first)
- # TODO: this is hack to support `promo_feature_available?`
- # We enable all feature flags by default unless they are `promo_`
- # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/218667
- feature_flag = true unless args.first.to_s.start_with?('promo_')
+ feature_flag = true
end
feature_flag
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index 7dae960410d..641ed24207e 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -247,8 +247,9 @@ module TestEnv
'GitLab Workhorse',
install_dir: workhorse_dir,
version: Gitlab::Workhorse.version,
- task: "gitlab:workhorse:install[#{install_workhorse_args}]"
- )
+ task: "gitlab:workhorse:install[#{install_workhorse_args}]") do
+ Gitlab::SetupHelper::Workhorse.create_configuration(workhorse_dir, nil)
+ end
end
def workhorse_dir
@@ -259,16 +260,22 @@ module TestEnv
host = "[#{host}]" if host.include?(':')
listen_addr = [host, port].join(':')
+ config_path = Gitlab::SetupHelper::Workhorse.get_config_path(workhorse_dir)
+
+ # This should be set up in setup_workhorse, but since
+ # component_needs_update? only checks that versions are consistent,
+ # we need to ensure the config file exists. This line can be removed
+ # later after a new Workhorse version is updated.
+ Gitlab::SetupHelper::Workhorse.create_configuration(workhorse_dir, nil) unless File.exist?(config_path)
+
workhorse_pid = spawn(
+ { 'PATH' => "#{ENV['PATH']}:#{workhorse_dir}" },
File.join(workhorse_dir, 'gitlab-workhorse'),
'-authSocket', upstream,
'-documentRoot', Rails.root.join('public').to_s,
'-listenAddr', listen_addr,
'-secretPath', Gitlab::Workhorse.secret_path.to_s,
- # TODO: Needed for workhorse + redis features.
- # https://gitlab.com/gitlab-org/gitlab/-/issues/209245
- #
- # '-config', '',
+ '-config', config_path,
'-logFile', 'log/workhorse-test.log',
'-logFormat', 'structured',
'-developmentMode' # to serve assets and rich error messages
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index fab775dd404..d92fcdc2d4a 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -88,6 +88,8 @@ module UsageDataHelpers
projects_jira_active
projects_jira_server_active
projects_jira_cloud_active
+ projects_jira_dvcs_cloud_active
+ projects_jira_dvcs_server_active
projects_slack_active
projects_slack_slash_commands_active
projects_custom_issue_tracker_active
@@ -136,6 +138,7 @@ module UsageDataHelpers
USAGE_DATA_KEYS = %i(
active_user_count
counts
+ counts_monthly
recorded_at
edition
version
@@ -160,6 +163,7 @@ module UsageDataHelpers
web_ide_clientside_preview_enabled
ingress_modsecurity_enabled
object_store
+ topology
).freeze
def stub_usage_data_connections
@@ -220,17 +224,8 @@ module UsageDataHelpers
)
end
- def expect_prometheus_api_to(*receive_matchers)
- expect_next_instance_of(Gitlab::PrometheusClient) do |client|
- receive_matchers.each { |m| expect(client).to m }
- end
- end
-
- def allow_prometheus_queries
- allow_next_instance_of(Gitlab::PrometheusClient) do |client|
- allow(client).to receive(:aggregate).and_return({})
- allow(client).to receive(:query).and_return({})
- end
+ def expect_prometheus_client_to(*receive_matchers)
+ receive_matchers.each { |m| expect(prometheus_client).to m }
end
def for_defined_days_back(days: [29, 2])
diff --git a/spec/support/helpers/wiki_helpers.rb b/spec/support/helpers/wiki_helpers.rb
index ae0d53d1297..e59c6bde264 100644
--- a/spec/support/helpers/wiki_helpers.rb
+++ b/spec/support/helpers/wiki_helpers.rb
@@ -3,6 +3,11 @@
module WikiHelpers
extend self
+ def stub_group_wikis(enabled)
+ stub_feature_flags(group_wikis: enabled)
+ stub_licensed_features(group_wikis: enabled)
+ end
+
def wait_for_svg_to_be_loaded(example = nil)
# Ensure the SVG is loaded first before clicking the button
find('.svg-content .js-lazy-loaded') if example.nil? || example.metadata.key?(:js)
diff --git a/spec/support/helpers/workhorse_helpers.rb b/spec/support/helpers/workhorse_helpers.rb
index f16b6c1e910..7e95f49aea2 100644
--- a/spec/support/helpers/workhorse_helpers.rb
+++ b/spec/support/helpers/workhorse_helpers.rb
@@ -3,6 +3,8 @@
module WorkhorseHelpers
extend self
+ UPLOAD_PARAM_NAMES = %w[name size path remote_id sha256 type].freeze
+
def workhorse_send_data
@_workhorse_send_data ||= begin
header = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]
@@ -59,6 +61,7 @@ module WorkhorseHelpers
file = workhorse_params.delete(key)
rewritten_fields[key] = file.path if file
workhorse_params = workhorse_disk_accelerated_file_params(key, file).merge(workhorse_params)
+ workhorse_params = workhorse_params.merge(jwt_file_upload_param(key: key, params: workhorse_params))
end
headers = if send_rewritten_field
@@ -74,8 +77,19 @@ module WorkhorseHelpers
private
- def jwt_token(data = {})
- JWT.encode({ 'iss' => 'gitlab-workhorse' }.merge(data), Gitlab::Workhorse.secret, 'HS256')
+ def jwt_file_upload_param(key:, params:)
+ upload_params = UPLOAD_PARAM_NAMES.map do |file_upload_param|
+ [file_upload_param, params["#{key}.#{file_upload_param}"]]
+ end
+ upload_params = upload_params.to_h.compact
+
+ return {} if upload_params.empty?
+
+ { "#{key}.gitlab-workhorse-upload" => jwt_token('upload' => upload_params) }
+ end
+
+ def jwt_token(data = {}, issuer: 'gitlab-workhorse', secret: Gitlab::Workhorse.secret, algorithm: 'HS256')
+ JWT.encode({ 'iss' => issuer }.merge(data), secret, algorithm)
end
def workhorse_rewritten_fields_header(fields)
diff --git a/spec/support/import_export/configuration_helper.rb b/spec/support/import_export/configuration_helper.rb
index 6f67b0f3dd7..b731ee626c0 100644
--- a/spec/support/import_export/configuration_helper.rb
+++ b/spec/support/import_export/configuration_helper.rb
@@ -44,8 +44,8 @@ module ConfigurationHelper
import_export_config = config_hash(config)
excluded_attributes = import_export_config[:excluded_attributes][relation_name.to_sym]
included_attributes = import_export_config[:included_attributes][relation_name.to_sym]
- attributes = attributes - Gitlab::Json.parse(excluded_attributes.to_json) if excluded_attributes
- attributes = attributes & Gitlab::Json.parse(included_attributes.to_json) if included_attributes
+ attributes -= Gitlab::Json.parse(excluded_attributes.to_json) if excluded_attributes
+ attributes &= Gitlab::Json.parse(included_attributes.to_json) if included_attributes
attributes
end
diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb
index 103019d8dd8..6e75fa58700 100644
--- a/spec/support/matchers/markdown_matchers.rb
+++ b/spec/support/matchers/markdown_matchers.rb
@@ -174,6 +174,15 @@ module MarkdownMatchers
end
end
+ # AlertReferenceFilter
+ matcher :reference_alerts do
+ set_default_markdown_messages
+
+ match do |actual|
+ expect(actual).to have_selector('a.gfm.gfm-alert', count: 5)
+ end
+ end
+
# TaskListFilter
matcher :parse_task_lists do
set_default_markdown_messages
diff --git a/spec/support/shared_contexts/features/file_uploads_shared_context.rb b/spec/support/shared_contexts/features/file_uploads_shared_context.rb
new file mode 100644
index 00000000000..972d25e81d2
--- /dev/null
+++ b/spec/support/shared_contexts/features/file_uploads_shared_context.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'file upload requests helpers' do
+ def capybara_url(path)
+ "http://#{Capybara.current_session.server.host}:#{Capybara.current_session.server.port}#{path}"
+ end
+end
diff --git a/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb
index fc8f9d2f407..54022aeb494 100644
--- a/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb
+++ b/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb
@@ -5,4 +5,5 @@ RSpec.shared_context 'UsersFinder#execute filter by project context' do
let_it_be(:blocked_user) { create(:user, :blocked, username: 'notsorandom') }
let_it_be(:external_user) { create(:user, :external) }
let_it_be(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') }
+ let_it_be(:internal_user) { User.alert_bot }
end
diff --git a/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb b/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb
index f1554ea8e9f..ec5bea34e8b 100644
--- a/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb
+++ b/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb
@@ -1,42 +1,88 @@
# frozen_string_literal: true
-RSpec.shared_context 'multipart middleware context' do
- let(:app) { double(:app) }
- let(:middleware) { described_class.new(app) }
- let(:original_filename) { 'filename' }
-
- # Rails 5 doesn't combine the GET/POST parameters in
- # ActionDispatch::HTTP::Parameters if action_dispatch.request.parameters is set:
- # https://github.com/rails/rails/blob/aea6423f013ca48f7704c70deadf2cd6ac7d70a1/actionpack/lib/action_dispatch/http/parameters.rb#L41
- def get_params(env)
- req = ActionDispatch::Request.new(env)
- req.GET.merge(req.POST)
+# This context provides one temporary file for the multipart spec
+#
+# Here are the available variables:
+# - uploaded_file
+# - uploaded_filepath
+# - filename
+# - remote_id
+RSpec.shared_context 'with one temporary file for multipart' do |within_tmp_sub_dir: false|
+ let(:uploaded_filepath) { uploaded_file.path }
+
+ around do |example|
+ Tempfile.open('uploaded_file2') do |tempfile|
+ @uploaded_file = tempfile
+ @filename = 'test_file.png'
+ @remote_id = 'remote_id'
+
+ example.run
+ end
end
- def post_env(rewritten_fields, params, secret, issuer)
- token = JWT.encode({ 'iss' => issuer, 'rewritten_fields' => rewritten_fields }, secret, 'HS256')
- Rack::MockRequest.env_for(
- '/',
- method: 'post',
- params: params,
- described_class::RACK_ENV_KEY => token
- )
+ attr_reader :uploaded_file, :filename, :remote_id
+end
+
+# This context provides two temporary files for the multipart spec
+#
+# Here are the available variables:
+# - uploaded_file
+# - uploaded_filepath
+# - filename
+# - remote_id
+# - tmp_sub_dir (only when using within_tmp_sub_dir: true)
+# - uploaded_file2
+# - uploaded_filepath2
+# - filename2
+# - remote_id2
+RSpec.shared_context 'with two temporary files for multipart' do
+ include_context 'with one temporary file for multipart'
+
+ let(:uploaded_filepath2) { uploaded_file2.path }
+
+ around do |example|
+ Tempfile.open('uploaded_file2') do |tempfile|
+ @uploaded_file2 = tempfile
+ @filename2 = 'test_file2.png'
+ @remote_id2 = 'remote_id2'
+
+ example.run
+ end
end
- def with_tmp_dir(uploads_sub_dir, storage_path = '')
- Dir.mktmpdir do |dir|
- upload_dir = File.join(dir, storage_path, uploads_sub_dir)
- FileUtils.mkdir_p(upload_dir)
+ attr_reader :uploaded_file2, :filename2, :remote_id2
+end
+
+# This context provides three temporary files for the multipart spec
+#
+# Here are the available variables:
+# - uploaded_file
+# - uploaded_filepath
+# - filename
+# - remote_id
+# - tmp_sub_dir (only when using within_tmp_sub_dir: true)
+# - uploaded_file2
+# - uploaded_filepath2
+# - filename2
+# - remote_id2
+# - uploaded_file3
+# - uploaded_filepath3
+# - filename3
+# - remote_id3
+RSpec.shared_context 'with three temporary files for multipart' do
+ include_context 'with two temporary files for multipart'
- allow(Rails).to receive(:root).and_return(dir)
- allow(Dir).to receive(:tmpdir).and_return(File.join(Dir.tmpdir, 'tmpsubdir'))
- allow(GitlabUploader).to receive(:root).and_return(File.join(dir, storage_path))
+ let(:uploaded_filepath3) { uploaded_file3.path }
- Tempfile.open('top-level', upload_dir) do |tempfile|
- env = post_env({ 'file' => tempfile.path }, { 'file.name' => original_filename, 'file.path' => tempfile.path }, Gitlab::Workhorse.secret, 'gitlab-workhorse')
+ around do |example|
+ Tempfile.open('uploaded_file3') do |tempfile|
+ @uploaded_file3 = tempfile
+ @filename3 = 'test_file3.png'
+ @remote_id3 = 'remote_id3'
- yield dir, env
- end
+ example.run
end
end
+
+ attr_reader :uploaded_file3, :filename3, :remote_id3
end
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index e276a54224b..747358fc1e0 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -119,10 +119,10 @@ RSpec.shared_context 'group navbar structure' do
nav_item: _('Settings'),
nav_sub_items: [
_('General'),
+ _('Integrations'),
_('Projects'),
_('Repository'),
_('CI / CD'),
- _('Integrations'),
_('Webhooks'),
_('Audit Events')
]
@@ -138,6 +138,13 @@ RSpec.shared_context 'group navbar structure' do
}
end
+ let(:push_rules_nav_item) do
+ {
+ nav_item: _('Push Rules'),
+ nav_sub_items: []
+ }
+ end
+
let(:structure) do
[
{
@@ -160,6 +167,7 @@ RSpec.shared_context 'group navbar structure' do
nav_item: _('Merge Requests'),
nav_sub_items: []
},
+ (push_rules_nav_item if Gitlab.ee?),
{
nav_item: _('Kubernetes'),
nav_sub_items: []
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 5339fa003b9..3016494ac8d 100644
--- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
@@ -1,30 +1,36 @@
# frozen_string_literal: true
RSpec.shared_context 'ProjectPolicy context' do
+ let_it_be(:anonymous) { nil }
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(:admin) }
- let(:project) { create(:project, :public, namespace: owner.namespace) }
+ let_it_be(:non_member) { create(:user) }
+ let_it_be_with_refind(:private_project) { create(:project, :private, namespace: owner.namespace) }
+ let_it_be_with_refind(:internal_project) { create(:project, :internal, namespace: owner.namespace) }
+ let_it_be_with_refind(:public_project) { create(:project, :public, namespace: owner.namespace) }
let(:base_guest_permissions) do
%i[
- read_project read_board read_list read_wiki read_issue
- read_project_for_iids read_issue_iid read_label
- read_milestone read_snippet read_project_member read_note
- create_project create_issue create_note upload_file create_merge_request_in
- award_emoji
+ award_emoji create_issue create_merge_request_in create_note
+ create_project read_board read_issue read_issue_iid read_issue_link
+ read_label read_list read_milestone read_note read_project
+ read_project_for_iids read_project_member read_release read_snippet
+ read_wiki upload_file
]
end
let(:base_reporter_permissions) do
%i[
- download_code fork_project create_snippet update_issue
- admin_issue admin_label admin_list read_commit_status read_build
- read_container_image read_pipeline read_environment read_deployment
- read_merge_request download_wiki_code read_sentry_issue read_prometheus
+ admin_issue admin_issue_link admin_label admin_list create_snippet
+ download_code download_wiki_code fork_project metrics_dashboard
+ read_build read_commit_status read_confidential_issues
+ read_container_image read_deployment read_environment read_merge_request
+ read_metrics_dashboard_annotation read_pipeline read_prometheus
+ read_sentry_issue update_issue
]
end
@@ -34,37 +40,42 @@ RSpec.shared_context 'ProjectPolicy context' do
let(:developer_permissions) do
%i[
- admin_milestone admin_merge_request update_merge_request create_commit_status
- update_commit_status create_build update_build create_pipeline
- update_pipeline create_merge_request_from create_wiki push_code
- resolve_note create_container_image update_container_image
- create_environment create_deployment update_deployment create_release update_release
- update_environment daily_statistics
+ admin_merge_request admin_milestone 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 daily_statistics 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
]
end
let(:base_maintainer_permissions) do
%i[
- push_to_delete_protected_branch update_snippet
- admin_snippet admin_project_member admin_note admin_wiki admin_project
- admin_commit_status admin_build admin_container_image
- admin_pipeline admin_environment admin_deployment destroy_release add_cluster
+ add_cluster admin_build admin_commit_status admin_container_image
+ admin_deployment admin_environment admin_note admin_pipeline
+ admin_project admin_project_member admin_snippet admin_terraform_state
+ admin_wiki create_deploy_token destroy_deploy_token destroy_release
+ push_to_delete_protected_branch read_deploy_token update_snippet
]
end
let(:public_permissions) do
%i[
- download_code fork_project read_commit_status read_pipeline
- read_container_image build_download_code build_read_container_image
- download_wiki_code read_release
+ build_download_code build_read_container_image download_code
+ download_wiki_code fork_project read_commit_status read_container_image
+ read_pipeline read_release
]
end
let(:base_owner_permissions) do
%i[
- change_namespace change_visibility_level rename_project remove_project
- archive_project remove_fork_project destroy_merge_request destroy_issue
- set_issue_iid set_issue_created_at set_issue_updated_at set_note_created_at
+ archive_project change_namespace change_visibility_level destroy_issue
+ destroy_merge_request remove_fork_project remove_project rename_project
+ set_issue_created_at set_issue_iid set_issue_updated_at
+ set_note_created_at
]
end
@@ -79,10 +90,12 @@ RSpec.shared_context 'ProjectPolicy context' do
let(:maintainer_permissions) { base_maintainer_permissions + additional_maintainer_permissions }
let(:owner_permissions) { base_owner_permissions + additional_owner_permissions }
- before do
- project.add_guest(guest)
- project.add_maintainer(maintainer)
- project.add_developer(developer)
- project.add_reporter(reporter)
+ before_all do
+ [private_project, internal_project, public_project].each do |project|
+ project.add_guest(guest)
+ project.add_reporter(reporter)
+ project.add_developer(developer)
+ project.add_maintainer(maintainer)
+ end
end
end
diff --git a/spec/support/shared_contexts/project_service_jira_context.rb b/spec/support/shared_contexts/project_service_jira_context.rb
index 4ca5c99323a..8e01de70846 100644
--- a/spec/support/shared_contexts/project_service_jira_context.rb
+++ b/spec/support/shared_contexts/project_service_jira_context.rb
@@ -5,7 +5,7 @@ RSpec.shared_context 'project service Jira context' do
let(:test_url) { 'http://jira.example.com/rest/api/2/serverInfo' }
def fill_form(disable: false)
- click_active_toggle if disable
+ click_active_checkbox if disable
fill_in 'service_url', with: url
fill_in 'service_username', with: 'username'
diff --git a/spec/support/shared_contexts/project_service_shared_context.rb b/spec/support/shared_contexts/project_service_shared_context.rb
index a72d4901b72..b4b9ab456e0 100644
--- a/spec/support/shared_contexts/project_service_shared_context.rb
+++ b/spec/support/shared_contexts/project_service_shared_context.rb
@@ -18,19 +18,27 @@ RSpec.shared_context 'project service activation' do
click_link(name)
end
- def click_active_toggle
- find('input[name="service[active]"] + button').click
+ def click_active_checkbox
+ find('input[name="service[active]"]').click
+ end
+
+ def click_save_integration
+ click_button('Save changes')
end
def click_test_integration
- click_button('Test settings and save changes')
+ click_link('Test settings')
end
- def click_test_then_save_integration
+ def click_test_then_save_integration(expect_test_to_fail: true)
click_test_integration
- expect(page).to have_content('Test failed.')
+ if expect_test_to_fail
+ expect(page).to have_content('Connection failed.')
+ else
+ expect(page).to have_content('Connection successful.')
+ end
- click_link('Save anyway')
+ click_save_integration
end
end
diff --git a/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb b/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb
new file mode 100644
index 00000000000..580ebf00dcb
--- /dev/null
+++ b/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'conan api setup' do
+ include PackagesManagerApiSpecHelpers
+ include HttpBasicAuthHelpers
+
+ let(:package) { create(:conan_package) }
+ let_it_be(:personal_access_token) { create(:personal_access_token) }
+ let_it_be(:user) { personal_access_token.user }
+ let_it_be(:base_secret) { SecureRandom.base64(64) }
+ let_it_be(:job) { create(:ci_build, :running, user: user) }
+ let_it_be(:job_token) { job.token }
+ let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
+
+ let(:project) { package.project }
+ let(:auth_token) { personal_access_token.token }
+ let(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
+
+ let(:headers) do
+ { 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('foo', auth_token) }
+ end
+
+ let(:jwt_secret) do
+ OpenSSL::HMAC.hexdigest(
+ OpenSSL::Digest::SHA256.new,
+ base_secret,
+ Gitlab::ConanToken::HMAC_KEY
+ )
+ end
+
+ before do
+ project.add_developer(user)
+ allow(Settings).to receive(:attr_encrypted_db_key_base).and_return(base_secret)
+ end
+end
+
+RSpec.shared_context 'conan recipe endpoints' do
+ include PackagesManagerApiSpecHelpers
+ include HttpBasicAuthHelpers
+
+ let(:jwt) { build_jwt(personal_access_token) }
+ let(:headers) { build_token_auth_header(jwt.encoded) }
+ let(:conan_package_reference) { '123456789' }
+ let(:presenter) { double('::Packages::Conan::PackagePresenter') }
+
+ before do
+ allow(::Packages::Conan::PackagePresenter).to receive(:new)
+ .with(package, user, package.project, any_args)
+ .and_return(presenter)
+ end
+end
+
+RSpec.shared_context 'conan file download endpoints' do
+ include PackagesManagerApiSpecHelpers
+ include HttpBasicAuthHelpers
+
+ let(:jwt) { build_jwt(personal_access_token) }
+ let(:headers) { build_token_auth_header(jwt.encoded) }
+ let(:recipe_path) { package.conan_recipe_path }
+ let(:package_file) { package.package_files.find_by(file_name: 'conaninfo.txt') }
+ let(:recipe_file) { package.package_files.find_by(file_name: 'conanfile.py') }
+ let(:metadata) { package_file.conan_file_metadatum }
+end
+
+RSpec.shared_context 'conan file upload endpoints' do
+ include PackagesManagerApiSpecHelpers
+ include WorkhorseHelpers
+ include HttpBasicAuthHelpers
+
+ let(:jwt) { build_jwt(personal_access_token) }
+ let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') }
+ let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } }
+ let(:headers_with_token) { build_token_auth_header(jwt.encoded).merge(workhorse_header) }
+ let(:recipe_path) { "foo/bar/#{project.full_path.tr('/', '+')}/baz"}
+end
diff --git a/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb
new file mode 100644
index 00000000000..fce78957eba
--- /dev/null
+++ b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'group_group_link' do
+ let(:shared_with_group) { create(:group) }
+ let(:shared_group) { create(:group) }
+
+ let!(:group_group_link) do
+ create(
+ :group_group_link,
+ {
+ shared_group: shared_group,
+ shared_with_group: shared_with_group,
+ expires_at: '2020-05-12'
+ }
+ )
+ end
+end
diff --git a/spec/support/shared_contexts/services_shared_context.rb b/spec/support/shared_contexts/services_shared_context.rb
index 899b43ade01..2bd516a2339 100644
--- a/spec/support/shared_contexts/services_shared_context.rb
+++ b/spec/support/shared_contexts/services_shared_context.rb
@@ -2,6 +2,8 @@
Service.available_services_names.each do |service|
RSpec.shared_context service do
+ include JiraServiceHelper if service == 'jira'
+
let(:dashed_service) { service.dasherize }
let(:service_method) { "#{service}_service".to_sym }
let(:service_klass) { "#{service}_service".classify.constantize }
@@ -39,6 +41,7 @@ Service.available_services_names.each do |service|
before do
enable_license_for_service(service)
+ stub_jira_service_test if service == 'jira'
end
def initialize_service(service)
diff --git a/spec/support/shared_examples/ci/jobs_shared_examples.rb b/spec/support/shared_examples/ci/jobs_shared_examples.rb
new file mode 100644
index 00000000000..d952d4a98eb
--- /dev/null
+++ b/spec/support/shared_examples/ci/jobs_shared_examples.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'a job with artifacts and trace' do |result_is_array: true|
+ context 'with artifacts and trace' do
+ let!(:second_job) { create(:ci_build, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) }
+
+ it 'returns artifacts and trace data', :skip_before_request do
+ get api(api_endpoint, api_user)
+ json_job = json_response.is_a?(Array) ? json_response.find { |job| job['id'] == second_job.id } : json_response
+
+ expect(json_job['artifacts_file']).not_to be_nil
+ expect(json_job['artifacts_file']).not_to be_empty
+ expect(json_job['artifacts_file']['filename']).to eq(second_job.artifacts_file.filename)
+ expect(json_job['artifacts_file']['size']).to eq(second_job.artifacts_file.size)
+ expect(json_job['artifacts']).not_to be_nil
+ expect(json_job['artifacts']).to be_an Array
+ expect(json_job['artifacts'].size).to eq(second_job.job_artifacts.length)
+ json_job['artifacts'].each do |artifact|
+ expect(artifact).not_to be_nil
+ file_type = Ci::JobArtifact.file_types[artifact['file_type']]
+ expect(artifact['size']).to eq(second_job.job_artifacts.find_by(file_type: file_type).size)
+ expect(artifact['filename']).to eq(second_job.job_artifacts.find_by(file_type: file_type).filename)
+ expect(artifact['file_format']).to eq(second_job.job_artifacts.find_by(file_type: file_type).file_format)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/controllers/binary_blob_shared_examples.rb b/spec/support/shared_examples/controllers/binary_blob_shared_examples.rb
deleted file mode 100644
index acce7642cfe..00000000000
--- a/spec/support/shared_examples/controllers/binary_blob_shared_examples.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'editing snippet checks blob is binary' do
- let(:snippets_binary_blob_value) { true }
-
- before do
- sign_in(user)
-
- allow_next_instance_of(Blob) do |blob|
- allow(blob).to receive(:binary?).and_return(binary)
- end
-
- stub_feature_flags(snippets_binary_blob: snippets_binary_blob_value)
-
- subject
- end
-
- context 'when blob is text' do
- let(:binary) { false }
-
- it 'responds with status 200' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template(:edit)
- end
- end
-
- context 'when blob is binary' do
- let(:binary) { true }
-
- it 'responds with status 200' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template(:edit)
- end
-
- context 'when feature flag :snippets_binary_blob is disabled' do
- let(:snippets_binary_blob_value) { false }
-
- it 'redirects away' do
- expect(response).to redirect_to(gitlab_snippet_path(snippet))
- end
- end
- end
-end
-
-RSpec.shared_examples 'updating snippet checks blob is binary' do
- let(:snippets_binary_blob_value) { true }
-
- before do
- sign_in(user)
-
- allow_next_instance_of(Blob) do |blob|
- allow(blob).to receive(:binary?).and_return(binary)
- end
-
- stub_feature_flags(snippets_binary_blob: snippets_binary_blob_value)
-
- subject
- end
-
- context 'when blob is text' do
- let(:binary) { false }
-
- it 'updates successfully' do
- expect(snippet.reload.title).to eq title
- expect(response).to redirect_to(gitlab_snippet_path(snippet))
- end
- end
-
- context 'when blob is binary' do
- let(:binary) { true }
-
- it 'updates successfully' do
- expect(snippet.reload.title).to eq title
- expect(response).to redirect_to(gitlab_snippet_path(snippet))
- end
-
- context 'when feature flag :snippets_binary_blob is disabled' do
- let(:snippets_binary_blob_value) { false }
-
- it 'redirects away without updating' do
- expect(response).to redirect_to(gitlab_snippet_path(snippet))
- expect(snippet.reload.title).not_to eq title
- end
- end
- end
-end
diff --git a/spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb b/spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb
deleted file mode 100644
index 17087456720..00000000000
--- a/spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'instance statistics availability' do
- let(:user) { create(:user) }
-
- before do
- sign_in(user)
-
- stub_application_setting(usage_ping_enabled: true)
- end
-
- describe 'GET #index' do
- it 'is available when the feature is available publicly' do
- get :index
-
- expect(response).to have_gitlab_http_status(:success)
- end
-
- it 'renders a 404 when the feature is not available publicly' do
- stub_application_setting(instance_statistics_visibility_private: true)
-
- get :index
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
- context 'for admins' do
- let(:user) { create(:admin) }
-
- context 'when admin mode disabled' do
- it 'forbids access when the feature is not available publicly' do
- stub_application_setting(instance_statistics_visibility_private: true)
-
- get :index
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'when admin mode enabled', :enable_admin_mode do
- it 'allows access when the feature is not available publicly' do
- stub_application_setting(instance_statistics_visibility_private: true)
-
- get :index
-
- expect(response).to have_gitlab_http_status(:success)
- end
- end
- end
- end
-end
diff --git a/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb b/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb
index 62a1a07b6c1..02915206cc5 100644
--- a/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb
@@ -42,10 +42,6 @@ RSpec.shared_examples 'issuables list meta-data' do |issuable_type, action = nil
let(:result_issuable) { issuables.first }
let(:search) { result_issuable.title }
- before do
- stub_feature_flags(attempt_project_search_optimizations: true)
- end
-
# .simple_sorts is the same across all Sortable classes
sorts = ::Issue.simple_sorts.keys + %w[popularity priority label_priority]
sorts.each do |sort|
diff --git a/spec/support/shared_examples/controllers/unique_hll_events_examples.rb b/spec/support/shared_examples/controllers/unique_hll_events_examples.rb
new file mode 100644
index 00000000000..7e5a225f020
--- /dev/null
+++ b/spec/support/shared_examples/controllers/unique_hll_events_examples.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'tracking unique hll events' do |feature_flag|
+ context 'when format is HTML' do
+ let(:format) { :html }
+
+ it 'tracks unique event' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(expected_type, target_id)
+
+ subject
+ end
+
+ it 'tracks unique event if DNT is not enabled' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(expected_type, target_id)
+ request.headers['DNT'] = '0'
+
+ subject
+ end
+
+ it 'does not track unique event if DNT is enabled' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event).with(expected_type, target_id)
+ request.headers['DNT'] = '1'
+
+ subject
+ end
+
+ context 'when feature flag is disabled' do
+ it 'does not track unique event' do
+ stub_feature_flags(feature_flag => false)
+
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event).with(expected_type, target_id)
+
+ subject
+ end
+ end
+ end
+
+ context 'when format is JSON' do
+ let(:format) { :json }
+
+ it 'does not track unique event if the format is JSON' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event).with(expected_type, target_id)
+
+ subject
+ end
+ end
+end
diff --git a/spec/support/shared_examples/controllers/unique_visits_shared_examples.rb b/spec/support/shared_examples/controllers/unique_visits_shared_examples.rb
index 90588756eb0..428389a9a01 100644
--- a/spec/support/shared_examples/controllers/unique_visits_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/unique_visits_shared_examples.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
RSpec.shared_examples 'tracking unique visits' do |method|
+ let(:request_params) { {} }
+
it 'tracks unique visit if the format is HTML' do
expect_any_instance_of(Gitlab::Analytics::UniqueVisits).to receive(:track_visit).with(instance_of(String), target_id)
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 c89ee0d25ae..4ca400dd87b 100644
--- a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
@@ -388,7 +388,54 @@ RSpec.shared_examples 'wiki controller actions' do
end.not_to change { wiki.list_pages.size }
expect(response).to render_template('shared/wikis/edit')
- expect(flash[:alert]).to eq('Could not create wiki page')
+ end
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ let(:id_param) { wiki_title }
+
+ subject do
+ delete(:destroy,
+ params: routing_params.merge(
+ id: id_param
+ ))
+ end
+
+ context 'when page exists' do
+ it 'deletes the page' do
+ expect do
+ subject
+ end.to change { wiki.list_pages.size }.by(-1)
+ end
+
+ context 'but page cannot be deleted' do
+ before do
+ allow_next_instance_of(WikiPage) do |page|
+ allow(page).to receive(:delete).and_return(false)
+ end
+ end
+
+ it 'renders the edit state' do
+ expect do
+ subject
+ end.not_to change { wiki.list_pages.size }
+
+ expect(response).to render_template('shared/wikis/edit')
+ expect(assigns(:error).message).to eq('Could not delete wiki page')
+ end
+ end
+ end
+
+ context 'when page does not exist' do
+ let(:id_param) { 'nil' }
+
+ it 'renders 404' do
+ expect do
+ subject
+ end.not_to change { wiki.list_pages.size }
+
+ expect(response).to have_gitlab_http_status(:not_found)
end
end
end
diff --git a/spec/support/shared_examples/features/2fa_shared_examples.rb b/spec/support/shared_examples/features/2fa_shared_examples.rb
new file mode 100644
index 00000000000..ddc03e178ba
--- /dev/null
+++ b/spec/support/shared_examples/features/2fa_shared_examples.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'hardware device for 2fa' do |device_type|
+ include Spec::Support::Helpers::Features::TwoFactorHelpers
+
+ def register_device(device_type, **kwargs)
+ case device_type.downcase
+ when "u2f"
+ register_u2f_device(**kwargs)
+ when "webauthn"
+ register_webauthn_device(**kwargs)
+ else
+ raise "Unknown device type #{device_type}"
+ end
+ end
+
+ 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 disabled' do
+ before do
+ user.update_attribute(:otp_required_for_login, false)
+ end
+
+ it 'does not allow registering a new device' do
+ visit profile_account_path
+ click_on 'Enable two-factor authentication'
+
+ expect(page).to have_button("Set up new device", disabled: true)
+ end
+ end
+
+ describe 'when 2FA via OTP is enabled' do
+ it 'allows registering a new device with a name' do
+ visit profile_account_path
+ manage_two_factor_authentication
+ expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators")
+
+ device = register_device(device_type)
+
+ expect(page).to have_content(device.name)
+ expect(page).to have_content("Your #{device_type} device was registered")
+ end
+
+ it 'allows deleting a device' do
+ visit profile_account_path
+ manage_two_factor_authentication
+ expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators")
+
+ first_device = register_device(device_type)
+ second_device = register_device(device_type, name: 'My other device')
+
+ expect(page).to have_content(first_device.name)
+ expect(page).to have_content(second_device.name)
+
+ accept_confirm { click_on 'Delete', match: :first }
+
+ expect(page).to have_content('Successfully deleted')
+ expect(page.body).not_to have_content(first_device.name)
+ expect(page.body).to have_content(second_device.name)
+ end
+ end
+ end
+
+ describe 'fallback code 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
+ end
+
+ describe 'when no device is registered' do
+ before do
+ gitlab_sign_out
+ gitlab_sign_in(user)
+ end
+
+ it 'shows the fallback otp code UI' do
+ assert_fallback_ui(page)
+ end
+ end
+
+ describe 'when a device is registered' do
+ before do
+ manage_two_factor_authentication
+ register_device(device_type)
+ gitlab_sign_out
+ gitlab_sign_in(user)
+ 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')
+
+ assert_fallback_ui(page)
+ end
+ end
+ end
+end
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 487c38da7da..c9910487798 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
@@ -124,3 +124,16 @@ end
def get_textarea_height
page.evaluate_script('document.getElementById("merge_request_description").offsetHeight')
end
+
+RSpec.shared_examples 'an editable merge request with reviewers' do
+ it 'updates merge request', :js do
+ 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
+ end
+end
diff --git a/spec/support/shared_examples/features/error_tracking_shared_example.rb b/spec/support/shared_examples/features/error_tracking_shared_example.rb
index ae7d62f31a2..92fc54ce0b0 100644
--- a/spec/support/shared_examples/features/error_tracking_shared_example.rb
+++ b/spec/support/shared_examples/features/error_tracking_shared_example.rb
@@ -36,10 +36,10 @@ end
RSpec.shared_examples 'expanded stack trace context' do |selected_line: nil, expected_line: 1|
it 'expands the stack trace context', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/217810' } do
within('div.stacktrace') do
- find("div.file-holder:nth-child(#{selected_line}) svg.ic-chevron-right").click if selected_line
+ find("div.file-holder:nth-child(#{selected_line}) svg[data-testid='chevron-right-icon']").click if selected_line
expanded_line = find("div.file-holder:nth-child(#{expected_line})")
- expect(expanded_line).to have_css('svg.ic-chevron-down')
+ expect(expanded_line).to have_css('svg[data-testid="chevron-down-icon"]')
event_response['entries'][0]['data']['values'][0]['stacktrace']['frames'][-expected_line]['context'].each do |context|
expect(page).to have_content(context[0])
diff --git a/spec/support/shared_examples/features/file_uploads_shared_examples.rb b/spec/support/shared_examples/features/file_uploads_shared_examples.rb
new file mode 100644
index 00000000000..ea8c8d44501
--- /dev/null
+++ b/spec/support/shared_examples/features/file_uploads_shared_examples.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'handling file uploads' do |shared_examples_name|
+ context 'with object storage disabled' do
+ context 'with upload_middleware_jwt_params_handler disabled' do
+ before do
+ stub_feature_flags(upload_middleware_jwt_params_handler: false)
+
+ expect_next_instance_of(Gitlab::Middleware::Multipart::Handler) do |handler|
+ expect(handler).to receive(:with_open_files).and_call_original
+ end
+ end
+
+ it_behaves_like shared_examples_name
+ end
+
+ context 'with upload_middleware_jwt_params_handler enabled' do
+ before do
+ stub_feature_flags(upload_middleware_jwt_params_handler: true)
+
+ expect_next_instance_of(Gitlab::Middleware::Multipart::HandlerForJWTParams) do |handler|
+ expect(handler).to receive(:with_open_files).and_call_original
+ end
+ end
+
+ it_behaves_like shared_examples_name
+ end
+ 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 6debbf81fc0..f201421e827 100644
--- a/spec/support/shared_examples/features/packages_shared_examples.rb
+++ b/spec/support/shared_examples/features/packages_shared_examples.rb
@@ -14,7 +14,7 @@ RSpec.shared_examples 'packages list' do |check_project_name: false|
end
def package_table_row(index)
- page.all("#{packages_table_selector} > [data-qa-selector=\"packages-row\"]")[index].text
+ page.all("#{packages_table_selector} > [data-qa-selector=\"package_row\"]")[index].text
end
end
@@ -32,7 +32,7 @@ RSpec.shared_examples 'package details link' do |property|
expect(page).to have_current_path(project_package_path(package.project, package))
- page.within('.detail-page-header') do
+ page.within('[data-qa-selector="package_title"]') do
expect(page).to have_content(package.name)
end
diff --git a/spec/support/shared_examples/graphql/members_shared_examples.rb b/spec/support/shared_examples/graphql/members_shared_examples.rb
index a58e716efd2..3a046c3feec 100644
--- a/spec/support/shared_examples/graphql/members_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/members_shared_examples.rb
@@ -20,3 +20,64 @@ RSpec.shared_examples 'a working membership object query' do |model_option|
).to eq('DEVELOPER')
end
end
+
+RSpec.shared_examples 'querying members with a group' do
+ let_it_be(:root_group) { create(:group, :private) }
+ let_it_be(:group_1) { create(:group, :private, parent: root_group, name: 'Main Group') }
+ let_it_be(:group_2) { create(:group, :private, parent: root_group) }
+
+ let_it_be(:user_1) { create(:user, name: 'test user') }
+ let_it_be(:user_2) { create(:user, name: 'test user 2') }
+ let_it_be(:user_3) { create(:user, name: 'another user 1') }
+ let_it_be(:user_4) { create(:user, name: 'another user 2') }
+
+ let_it_be(:root_group_member) { create(:group_member, user: user_4, group: root_group) }
+ let_it_be(:group_1_member) { create(:group_member, user: user_2, group: group_1) }
+ let_it_be(:group_2_member) { create(:group_member, user: user_3, group: group_2) }
+
+ let(:args) { {} }
+
+ subject do
+ resolve(described_class, obj: resource, args: args, ctx: { current_user: user_4 })
+ end
+
+ describe '#resolve' do
+ before do
+ group_1.add_maintainer(user_4)
+ end
+
+ it 'finds all resource members' do
+ expect(subject).to contain_exactly(resource_member, group_1_member, root_group_member)
+ end
+
+ context 'with search' do
+ context 'when the search term matches a user' do
+ let(:args) { { search: 'test' } }
+
+ it 'searches users by user name' do
+ expect(subject).to contain_exactly(resource_member, group_1_member)
+ end
+ end
+
+ context 'when the search term does not match any user' do
+ let(:args) { { search: 'nothing' } }
+
+ it 'is empty' do
+ expect(subject).to be_empty
+ end
+ end
+ end
+
+ context 'when user can not see resource members' do
+ let_it_be(:other_user) { create(:user) }
+
+ subject do
+ resolve(described_class, obj: resource, args: args, ctx: { current_user: other_user })
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/graphql/mutation_shared_examples.rb b/spec/support/shared_examples/graphql/mutation_shared_examples.rb
index 86d2bb6c747..b67cac94547 100644
--- a/spec/support/shared_examples/graphql/mutation_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/mutation_shared_examples.rb
@@ -19,6 +19,13 @@ RSpec.shared_examples 'a mutation that returns top-level errors' do |errors: []|
end
end
+# There must be a method or let called `mutation` defined that executes
+# 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]
+end
+
RSpec.shared_examples 'an invalid argument to the mutation' do |argument_name:|
it_behaves_like 'a mutation that returns top-level errors' do
let(:match_errors) do
diff --git a/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb b/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb
index 2ef71d275a2..7627a7b4d59 100644
--- a/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb
@@ -84,6 +84,9 @@ RSpec.shared_examples 'sorted paginated query' do
cursored_query = pagination_query([sort_argument, "after: \"#{end_cursor}\""].compact.join(','), page_info)
post_graphql(cursored_query, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+
response_data = graphql_dig_at(Gitlab::Json.parse(response.body), :data, *data_path, :edges)
expect(pagination_results_data(response_data)).to eq expected_results.drop(first_param)
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
new file mode 100644
index 00000000000..ed139e638bf
--- /dev/null
+++ b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'Gitlab-style deprecations' do
+ describe 'validations' do
+ it 'raises an informative error if `deprecation_reason` is used' do
+ expect { subject(deprecation_reason: 'foo') }.to raise_error(
+ ArgumentError,
+ 'Use `deprecated` property instead of `deprecation_reason`. ' \
+ 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-and-enum-values'
+ )
+ end
+
+ it 'raises an error if a required property is missing', :aggregate_failures do
+ expect { subject(deprecated: { milestone: '1.10' }) }.to raise_error(
+ ArgumentError,
+ 'Please provide a `reason` within `deprecated`'
+ )
+ expect { subject(deprecated: { reason: 'Deprecation reason' }) }.to raise_error(
+ ArgumentError,
+ 'Please provide a `milestone` within `deprecated`'
+ )
+ end
+
+ it 'raises an error if milestone is not a String', :aggregate_failures do
+ expect { subject(deprecated: { milestone: 1.10, reason: 'Deprecation reason' }) }.to raise_error(
+ ArgumentError,
+ '`milestone` must be a `String`'
+ )
+ end
+ end
+
+ it 'adds a formatted `deprecated_reason` to the subject' do
+ deprecable = subject(deprecated: { milestone: '1.10', reason: 'Deprecation reason' })
+
+ expect(deprecable.deprecation_reason).to eq('Deprecation reason. Deprecated in 1.10')
+ end
+
+ it 'appends to the description if given' do
+ deprecable = subject(
+ deprecated: { milestone: '1.10', reason: 'Deprecation reason' },
+ description: 'Deprecable description'
+ )
+
+ expect(deprecable.description).to eq('Deprecable description. Deprecated in 1.10: Deprecation reason')
+ end
+
+ it 'does not append to the description if it is absent' do
+ deprecable = subject(deprecated: { milestone: '1.10', reason: 'Deprecation reason' })
+
+ expect(deprecable.description).to be_nil
+ end
+end
diff --git a/spec/support/shared_examples/lib/banzai/reference_parser_shared_examples.rb b/spec/support/shared_examples/lib/banzai/reference_parser_shared_examples.rb
index d903c0f10e0..479b26977e2 100644
--- a/spec/support/shared_examples/lib/banzai/reference_parser_shared_examples.rb
+++ b/spec/support/shared_examples/lib/banzai/reference_parser_shared_examples.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples "referenced feature visibility" do |*related_features|
+ let(:enable_user?) { false }
let(:feature_fields) do
related_features.map { |feature| (feature + "_access_level").to_sym }
end
@@ -35,8 +36,11 @@ RSpec.shared_examples "referenced feature visibility" do |*related_features|
end
context "when feature is enabled" do
- # The project is public
+ # Allows implementing specs to enable finer-tuned permissions
+ let(:enable_user?) { true }
+
it "creates reference" do
+ # The project is public
set_features_fields_to(ProjectFeature::ENABLED)
expect(subject.nodes_visible_to_user(user, [link])).to eq([link])
diff --git a/spec/support/shared_examples/lib/gitlab/alert_management/payload.rb b/spec/support/shared_examples/lib/gitlab/alert_management/payload.rb
new file mode 100644
index 00000000000..54b021e8371
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/alert_management/payload.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'parsable alert payload field with fallback' do |fallback, *paths|
+ context 'without payload' do
+ it { is_expected.to eq(fallback) }
+ end
+
+ paths.each do |path|
+ context "with #{path}" do
+ let(:value) { 'some value' }
+
+ before do
+ section, name = path.split('/')
+ raw_payload[section] = name ? { name => value } : value
+ end
+
+ it { is_expected.to eq(value) }
+ end
+ end
+end
+
+RSpec.shared_examples 'parsable alert payload field' do |*paths|
+ it_behaves_like 'parsable alert payload field with fallback', nil, *paths
+end
+
+RSpec.shared_examples 'subclass has expected api' do
+ it 'defines all public methods in the base class' do
+ default_methods = Gitlab::AlertManagement::Payload::Base.public_instance_methods
+ subclass_methods = described_class.public_instance_methods
+ missing_methods = subclass_methods - default_methods
+
+ expect(missing_methods).to be_empty
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/auth/atlassian_identity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/auth/atlassian_identity_shared_examples.rb
new file mode 100644
index 00000000000..18a5087da3b
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/auth/atlassian_identity_shared_examples.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'an atlassian identity' do
+ it 'sets the proper values' do
+ expect(identity.extern_uid).to eq(extern_uid)
+ expect(identity.token).to eq(credentials[:token])
+ expect(identity.refresh_token).to eq(credentials[:refresh_token])
+ expect(identity.expires_at.to_i).to eq(credentials[:expires_at])
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb
index 8cf6babe146..e93077c42e1 100644
--- a/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb
@@ -63,7 +63,7 @@ RSpec.shared_examples 'schedules resource mentions migration' do |resource_class
it 'schedules background migrations' do
Sidekiq::Testing.fake! do
- Timecop.freeze do
+ freeze_time do
resource_count = is_for_notes ? Note.count : resource_class.count
expect(resource_count).to eq 5
diff --git a/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb
index a3800f050bb..f018ece0d46 100644
--- a/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb
@@ -5,18 +5,19 @@ RSpec.shared_examples 'network policy common specs' do
let(:namespace) { 'example-namespace' }
let(:labels) { nil }
+ describe '#generate' do
+ subject { policy.generate }
+
+ it { is_expected.to eq(Kubeclient::Resource.new(policy.resource)) }
+ end
+
describe 'as_json' do
let(:json_policy) do
{
name: name,
namespace: namespace,
creation_timestamp: nil,
- manifest: YAML.dump(
- {
- metadata: metadata,
- spec: spec
- }.deep_stringify_keys
- ),
+ manifest: YAML.dump(policy.resource.deep_stringify_keys),
is_autodevops: false,
is_enabled: true
}
diff --git a/spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb
new file mode 100644
index 00000000000..6327367fcc2
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'handling all upload parameters conditions' do
+ context 'one root parameter' do
+ include_context 'with one temporary file for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
+ let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename, remote_id: remote_id) }
+
+ it 'builds an UploadedFile' do
+ expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file))
+
+ subject
+ end
+ end
+
+ context 'two root parameters' do
+ include_context 'with two temporary files for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('file1' => uploaded_filepath, 'file2' => uploaded_filepath2) }
+ let(:params) do
+ upload_parameters_for(filepath: uploaded_filepath, key: 'file1', filename: filename, remote_id: remote_id).merge(
+ upload_parameters_for(filepath: uploaded_filepath2, key: 'file2', filename: filename2, remote_id: remote_id2)
+ )
+ end
+
+ it 'builds UploadedFiles' do
+ expect_uploaded_files([
+ { filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file1) },
+ { filepath: uploaded_filepath2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(file2) }
+ ])
+
+ subject
+ end
+ end
+
+ context 'one nested parameter' do
+ include_context 'with one temporary file for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('user[avatar]' => uploaded_filepath) }
+ let(:params) { { 'user' => { 'avatar' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) } } }
+
+ it 'builds an UploadedFile' do
+ expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar))
+
+ subject
+ end
+ end
+
+ context 'two nested parameters' do
+ include_context 'with two temporary files for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('user[avatar]' => uploaded_filepath, 'user[screenshot]' => uploaded_filepath2) }
+ let(:params) do
+ {
+ 'user' => {
+ 'avatar' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id),
+ 'screenshot' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2)
+ }
+ }
+ end
+
+ it 'builds UploadedFiles' do
+ expect_uploaded_files([
+ { filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar) },
+ { filepath: uploaded_filepath2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(user screenshot) }
+ ])
+
+ subject
+ end
+ end
+
+ context 'one deeply nested parameter' do
+ include_context 'with one temporary file for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('user[avatar][bananas]' => uploaded_filepath) }
+ let(:params) { { 'user' => { 'avatar' => { 'bananas' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) } } } }
+
+ it 'builds an UploadedFile' do
+ expect_uploaded_files(filepath: uploaded_file, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar bananas))
+
+ subject
+ end
+ end
+
+ context 'two deeply nested parameters' do
+ include_context 'with two temporary files for multipart'
+
+ let(:rewritten_fields) { rewritten_fields_hash('user[avatar][bananas]' => uploaded_filepath, 'user[friend][ananas]' => uploaded_filepath2) }
+ let(:params) do
+ {
+ 'user' => {
+ 'avatar' => {
+ 'bananas' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id)
+ },
+ 'friend' => {
+ 'ananas' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2)
+ }
+ }
+ }
+ end
+
+ it 'builds UploadedFiles' do
+ expect_uploaded_files([
+ { filepath: uploaded_file, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar bananas) },
+ { filepath: uploaded_file2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(user friend ananas) }
+ ])
+
+ subject
+ end
+ end
+
+ context 'three parameters nested at different levels' do
+ include_context 'with three temporary files for multipart'
+
+ let(:rewritten_fields) do
+ rewritten_fields_hash(
+ 'file' => uploaded_filepath,
+ 'user[avatar]' => uploaded_filepath2,
+ 'user[friend][avatar]' => uploaded_filepath3
+ )
+ end
+
+ let(:params) do
+ upload_parameters_for(filepath: uploaded_filepath, filename: filename, key: 'file', remote_id: remote_id).merge(
+ 'user' => {
+ 'avatar' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2),
+ 'friend' => {
+ 'avatar' => upload_parameters_for(filepath: uploaded_filepath3, filename: filename3, remote_id: remote_id3)
+ }
+ }
+ )
+ end
+
+ it 'builds UploadedFiles' do
+ expect_uploaded_files([
+ { filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file) },
+ { filepath: uploaded_filepath2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(user avatar) },
+ { filepath: uploaded_filepath3, original_filename: filename3, remote_id: remote_id3, size: uploaded_file3.size, params_path: %w(user friend avatar) }
+ ])
+
+ subject
+ end
+ end
+end
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
new file mode 100644
index 00000000000..94ef41ce5a5
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'access restricted confidential issues' do
+ let(:query) { 'issue' }
+ let(:author) { create(:user) }
+ let(:assignee) { create(:user) }
+ let(:project) { create(:project, :internal) }
+
+ let!(:issue) { create(:issue, project: project, title: 'Issue 1') }
+ let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) }
+ let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) }
+
+ subject(:objects) do
+ described_class.new(user, query, project: project).objects('issues')
+ end
+
+ context 'when the user is non-member' do
+ let(:user) { create(:user) }
+
+ it 'does not list project confidential issues for non project members' do
+ expect(objects).to contain_exactly(issue)
+ expect(results.limited_issues_count).to eq 1
+ end
+ end
+
+ context 'when the member is guest' do
+ let(:user) do
+ create(:user) { |guest| project.add_guest(guest) }
+ end
+
+ it 'does not list project confidential issues for project members with guest role' do
+ expect(objects).to contain_exactly(issue)
+ expect(results.limited_issues_count).to eq 1
+ end
+ end
+
+ context 'when the user is the author' do
+ let(:user) { author }
+
+ it 'lists project confidential issues' do
+ expect(objects).to contain_exactly(issue,
+ security_issue_1)
+ expect(results.limited_issues_count).to eq 2
+ end
+ end
+
+ context 'when the user is the assignee' do
+ let(:user) { assignee }
+
+ it 'lists project confidential issues for assignee' do
+ expect(objects).to contain_exactly(issue,
+ security_issue_2)
+ expect(results.limited_issues_count).to eq 2
+ end
+ end
+
+ context 'when the user is a developper' do
+ let(:user) do
+ create(:user) { |user| project.add_developer(user) }
+ end
+
+ it 'lists project confidential issues' do
+ expect(objects).to contain_exactly(issue,
+ security_issue_1,
+ security_issue_2)
+ expect(results.limited_issues_count).to eq 3
+ end
+ end
+
+ context 'when the user is admin', :request_store do
+ let(:user) { create(:user, admin: true) }
+
+ it 'lists all project issues' do
+ expect(objects).to contain_exactly(issue,
+ security_issue_1,
+ security_issue_2)
+ end
+ 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 4aeae788114..025f0d5c7ea 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
@@ -17,5 +17,9 @@ RSpec.shared_examples 'a repo type' do
it 'finds the repository for the repo type' do
expect(described_class.repository_for(expected_container)).to eq(expected_repository)
end
+
+ it 'returns nil when container is nil' do
+ expect(described_class.repository_for(nil)).to eq(nil)
+ end
end
end
diff --git a/spec/support/shared_examples/lib/gitlab/search/recent_items.rb b/spec/support/shared_examples/lib/gitlab/search/recent_items.rb
new file mode 100644
index 00000000000..f96ff4b101e
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/search/recent_items.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'search recent items' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:recent_items) { described_class.new(user: user, items_limit: 5) }
+ let(:item) { create_item(content: 'hello world 1', project: project) }
+ let(:project) { create(:project, :public) }
+
+ describe '#log_view', :clean_gitlab_redis_shared_state do
+ it 'adds the item to the recent items' do
+ recent_items.log_view(item)
+
+ results = recent_items.search('hello')
+
+ expect(results).to eq([item])
+ end
+
+ it 'removes an item when it exceeds the size items_limit' do
+ (1..6).each do |i|
+ recent_items.log_view(create_item(content: "item #{i}", project: project))
+ end
+
+ results = recent_items.search('item')
+
+ expect(results.map(&:title)).to contain_exactly('item 6', 'item 5', 'item 4', 'item 3', 'item 2')
+ end
+
+ it 'expires the items after expires_after' do
+ recent_items = described_class.new(user: user, expires_after: 0)
+
+ recent_items.log_view(item)
+
+ results = recent_items.search('hello')
+
+ expect(results).to be_empty
+ end
+
+ it 'does not include results logged for another user' do
+ another_user = create(:user)
+ another_item = create_item(content: 'hello world 2', project: project)
+ described_class.new(user: another_user).log_view(another_item)
+ recent_items.log_view(item)
+
+ results = recent_items.search('hello')
+
+ expect(results).to eq([item])
+ end
+ end
+
+ describe '#search', :clean_gitlab_redis_shared_state do
+ let(:item1) { create_item(content: "matching item 1", project: project) }
+ let(:item2) { create_item(content: "matching item 2", project: project) }
+ let(:item3) { create_item(content: "matching item 3", project: project) }
+ let(:non_matching_item) { create_item(content: "different item", project: project) }
+ let!(:non_viewed_item) { create_item(content: "matching but not viewed item", project: project) }
+
+ before do
+ recent_items.log_view(item1)
+ recent_items.log_view(item2)
+ recent_items.log_view(item3)
+ recent_items.log_view(non_matching_item)
+ end
+
+ it 'matches partial text in the item title' do
+ expect(recent_items.search('matching')).to contain_exactly(item1, item2, item3)
+ end
+
+ it 'returns results sorted by recently viewed' do
+ recent_items.log_view(item2)
+
+ expect(recent_items.search('matching')).to eq([item2, item3, item1])
+ end
+
+ it 'does not leak items you no longer have access to' do
+ private_project = create(:project, :public, namespace: create(:group))
+ private_item = create_item(content: 'matching item title', project: private_project)
+
+ recent_items.log_view(private_item)
+
+ private_project.update!(visibility_level: Project::PRIVATE)
+
+ expect(recent_items.search('matching')).not_to include(private_item)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb
new file mode 100644
index 00000000000..e80ec516407
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'search results filtered by state' do
+ context 'state not provided' do
+ let(:filters) { {} }
+
+ it 'returns opened and closed results', :aggregate_failures do
+ expect(results.objects(scope)).to include opened_result
+ expect(results.objects(scope)).to include closed_result
+ end
+ end
+
+ context 'all state' do
+ let(:filters) { { state: 'all' } }
+
+ it 'returns opened and closed results', :aggregate_failures do
+ expect(results.objects(scope)).to include opened_result
+ expect(results.objects(scope)).to include closed_result
+ end
+ end
+
+ context 'closed state' do
+ let(:filters) { { state: 'closed' } }
+
+ it 'returns only closed results', :aggregate_failures do
+ expect(results.objects(scope)).not_to include opened_result
+ expect(results.objects(scope)).to include closed_result
+ end
+ end
+
+ context 'opened state' do
+ let(:filters) { { state: 'opened' } }
+
+ it 'returns only opened results', :aggregate_failures do
+ expect(results.objects(scope)).to include opened_result
+ expect(results.objects(scope)).not_to include closed_result
+ end
+ end
+
+ context 'unsupported state' do
+ let(:filters) { { state: 'hello' } }
+
+ it 'returns only opened results', :aggregate_failures do
+ expect(results.objects(scope)).to include opened_result
+ expect(results.objects(scope)).to include closed_result
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/sql/set_operator_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/sql/set_operator_shared_examples.rb
new file mode 100644
index 00000000000..73beef06855
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/sql/set_operator_shared_examples.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'SQL set operator' do |operator_keyword|
+ operator_keyword = operator_keyword.upcase
+
+ let(:relation_1) { User.where(email: 'alice@example.com').select(:id) }
+ let(:relation_2) { User.where(email: 'bob@example.com').select(:id) }
+
+ def to_sql(relation)
+ relation.reorder(nil).to_sql
+ end
+
+ describe '.operator_keyword' do
+ it { expect(described_class.operator_keyword).to eq operator_keyword }
+ end
+
+ describe '#to_sql' do
+ it "returns a String joining relations together using a #{operator_keyword}" do
+ set_operator = described_class.new([relation_1, relation_2])
+
+ expect(set_operator.to_sql).to eq("(#{to_sql(relation_1)})\n#{operator_keyword}\n(#{to_sql(relation_2)})")
+ end
+
+ it 'skips Model.none segements' do
+ empty_relation = User.none
+ set_operator = described_class.new([empty_relation, relation_1, relation_2])
+
+ expect {User.where("users.id IN (#{set_operator.to_sql})").to_a}.not_to raise_error
+ expect(set_operator.to_sql).to eq("(#{to_sql(relation_1)})\n#{operator_keyword}\n(#{to_sql(relation_2)})")
+ end
+
+ it "uses #{operator_keyword} ALL when removing duplicates is disabled" do
+ set_operator = described_class
+ .new([relation_1, relation_2], remove_duplicates: false)
+
+ expect(set_operator.to_sql).to include("#{operator_keyword} ALL")
+ end
+
+ it 'returns `NULL` if all relations are empty' do
+ empty_relation = User.none
+ set_operator = described_class.new([empty_relation, empty_relation])
+
+ expect(set_operator.to_sql).to eq('NULL')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/usage_data_counters/incident_management_activity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/usage_data_counters/incident_management_activity_shared_examples.rb
new file mode 100644
index 00000000000..4e35e388b23
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/usage_data_counters/incident_management_activity_shared_examples.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'an incident management tracked event' do |event|
+ describe ".track_event", :clean_gitlab_redis_shared_state do
+ let(:counter) { Gitlab::UsageDataCounters::HLLRedisCounter }
+ let(:start_time) { 1.minute.ago }
+ let(:end_time) { 1.minute.from_now }
+
+ it "tracks the event using redis" do
+ # Allow other subsequent calls
+ allow(Gitlab::UsageDataCounters::HLLRedisCounter)
+ .to receive(:track_event)
+
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter)
+ .to receive(:track_event)
+ .with(current_user.id, event.to_s)
+ .and_call_original
+
+ expect { subject }
+ .to change { counter.unique_events(event_names: event.to_s, start_date: start_time, end_date: end_time) }
+ .by 1
+ end
+ end
+end
+
+RSpec.shared_examples 'does not track incident management event' do |event|
+ it 'does not track the event', :clean_gitlab_redis_shared_state do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter)
+ .not_to receive(:track_event)
+ .with(anything, event.to_s)
+ end
+end
diff --git a/spec/support/shared_examples/models/chat_slash_commands_shared_examples.rb b/spec/support/shared_examples/models/chat_slash_commands_shared_examples.rb
index 6611a168c04..0ee24dd93d7 100644
--- a/spec/support/shared_examples/models/chat_slash_commands_shared_examples.rb
+++ b/spec/support/shared_examples/models/chat_slash_commands_shared_examples.rb
@@ -85,7 +85,7 @@ RSpec.shared_examples 'chat slash commands service' do
let(:params) { { token: 'token', team_id: chat_name.team_id, user_id: chat_name.chat_id } }
subject do
- described_class.create(project: project, properties: { token: 'token' })
+ described_class.create!(project: project, properties: { token: 'token' })
end
it 'triggers the command' do
diff --git a/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb
index 394253fb699..ac8022a4726 100644
--- a/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb
+++ b/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'cluster application helm specs' do |application_name|
- let(:application) { create(application_name) }
+ let(:application) { create(application_name) } # rubocop:disable Rails/SaveBang
describe '#uninstall_command' do
subject { application.uninstall_command }
diff --git a/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb b/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb
new file mode 100644
index 00000000000..6b208c0024d
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'from set operator' do |sql_klass|
+ from_set_operator_concern = described_class
+ operator_keyword = sql_klass.operator_keyword
+ operator_method = "from_#{sql_klass.operator_keyword.downcase}"
+
+ describe "##{operator_method}" do
+ let(:model) do
+ Class.new(ActiveRecord::Base) do
+ self.table_name = 'users'
+
+ include from_set_operator_concern
+ end
+ end
+
+ it "selects from the results of the #{operator_keyword}" do
+ query = model.public_send(operator_method, [model.where(id: 1), model.where(id: 2)])
+
+ expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\n#{operator_keyword}\n\(SELECT.+\)\) users/m)
+ end
+
+ it 'supports the use of a custom alias for the sub query' do
+ query = model.public_send(operator_method,
+ [model.where(id: 1), model.where(id: 2)],
+ alias_as: 'kittens'
+ )
+
+ expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\n#{operator_keyword}\n\(SELECT.+\)\) kittens/m)
+ end
+
+ it 'supports keeping duplicate rows' do
+ query = model.public_send(operator_method,
+ [model.where(id: 1), model.where(id: 2)],
+ remove_duplicates: false
+ )
+
+ expect(query.to_sql)
+ .to match(/FROM \(\(SELECT.+\)\n#{operator_keyword} ALL\n\(SELECT.+\)\) users/m)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb
index d21823661f8..07d687147bc 100644
--- a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb
@@ -8,26 +8,29 @@ RSpec.shared_examples 'includes Limitable concern' do
context 'without plan limits configured' do
it 'can create new models' do
- expect { subject.save }.to change { described_class.count }
+ expect { subject.save! }.to change { described_class.count }
end
end
context 'with plan limits configured' do
before do
- plan_limits.update(subject.class.limit_name => 1)
+ plan_limits.update!(subject.class.limit_name => 1)
end
it 'can create new models' do
- expect { subject.save }.to change { described_class.count }
+ expect { subject.save! }.to change { described_class.count }
end
context 'with an existing model' do
before do
- subject.dup.save
+ subject.dup.save!
end
it 'cannot create new models exceeding the plan limits' do
- expect { subject.save }.not_to change { described_class.count }
+ expect do
+ expect { subject.save! }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+ .not_to change { described_class.count }
expect(subject.errors[:base]).to contain_exactly("Maximum number of #{subject.class.limit_name.humanize(capitalize: false)} (1) exceeded")
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 15ca1f56bd0..d199bae4170 100644
--- a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
@@ -102,7 +102,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
let(:timebox) { create(timebox_type, *timebox_args, group: group) }
before do
- project.update(group: group)
+ project.update!(group: group)
end
it "does not accept the same title in a group twice" do
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 8c3e073193c..64390ccdc25 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,7 +10,7 @@ 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,
+ 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}")
@@ -25,11 +25,11 @@ RSpec.shared_examples 'a valid diff note with after commit callback' do
it 'fallback to fetch file from repository' do
expect_any_instance_of(::Gitlab::Diff::Position).to receive(:diff_file).with(project.repository)
- subject.save
+ subject.save!
end
it 'creates a diff note file' do
- subject.save
+ subject.save!
expect(subject.reload.note_diff_file).to be_present
end
@@ -40,7 +40,7 @@ RSpec.shared_examples 'a valid diff note with after commit callback' do
it 'raises an error' do
allow_any_instance_of(::Gitlab::Diff::Position).to receive(:diff_file).with(project.repository).and_return(nil)
- expect { subject.save }.to raise_error(::DiffNote::NoteDiffFileCreationError, 'Failed to find diff file')
+ expect { subject.save! }.to raise_error(::DiffNote::NoteDiffFileCreationError, 'Failed to find diff file')
end
end
end
diff --git a/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb b/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb
index b0cdc77a378..759b22f794e 100644
--- a/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb
+++ b/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb
@@ -18,8 +18,6 @@ RSpec.shared_examples 'a valid diff positionable note' do |factory_on_commit|
)
end
- subject { build(factory_on_commit, commit_id: commit_id, position: position) }
-
context 'position diff refs matches commit diff refs' do
it 'is valid' do
expect(subject).to be_valid
diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb
index 9bf157212d3..7ede6f0d8d4 100644
--- a/spec/support/shared_examples/models/member_shared_examples.rb
+++ b/spec/support/shared_examples/models/member_shared_examples.rb
@@ -25,21 +25,21 @@ RSpec.shared_examples 'inherited access level as a member of entity' do
it 'is allowed to change to be a developer of the entity' do
entity.add_maintainer(user)
- expect { member.update(access_level: Gitlab::Access::DEVELOPER) }
+ expect { member.update!(access_level: Gitlab::Access::DEVELOPER) }
.to change { member.access_level }.to(Gitlab::Access::DEVELOPER)
end
it 'is not allowed to change to be a guest of the entity' do
entity.add_maintainer(user)
- expect { member.update(access_level: Gitlab::Access::GUEST) }
+ expect { member.update(access_level: Gitlab::Access::GUEST) } # rubocop:disable Rails/SaveBang
.not_to change { member.reload.access_level }
end
it "shows an error if the member can't be updated" do
entity.add_maintainer(user)
- member.update(access_level: Gitlab::Access::REPORTER)
+ expect { member.update!(access_level: Gitlab::Access::REPORTER) }.to raise_error(ActiveRecord::RecordInvalid)
expect(member.errors.full_messages).to eq(["Access level should be greater than or equal to Developer inherited membership from group #{parent_entity.name}"])
end
@@ -51,7 +51,7 @@ RSpec.shared_examples 'inherited access level as a member of entity' do
non_member = entity.is_a?(Group) ? entity.group_member(non_member_user) : entity.project_member(non_member_user)
- expect { non_member.update(access_level: Gitlab::Access::GUEST) }
+ expect { non_member.update!(access_level: Gitlab::Access::GUEST) }
.to change { non_member.reload.access_level }
end
end
@@ -60,7 +60,7 @@ end
RSpec.shared_examples '#valid_level_roles' do |entity_name|
let(:member_user) { create(:user) }
let(:group) { create(:group) }
- let(:entity) { create(entity_name) }
+ let(:entity) { create(entity_name) } # rubocop:disable Rails/SaveBang
let(:entity_member) { create("#{entity_name}_member", :developer, source: entity, user: member_user) }
let(:presenter) { described_class.new(entity_member, current_user: member_user) }
let(:expected_roles) { { 'Developer' => 30, 'Maintainer' => 40, 'Reporter' => 20 } }
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 050d710f1de..04af3935d15 100644
--- a/spec/support/shared_examples/models/members_notifications_shared_example.rb
+++ b/spec/support/shared_examples/models/members_notifications_shared_example.rb
@@ -13,7 +13,7 @@ RSpec.shared_examples 'members notifications' do |entity_type|
it "sends email to user" do
expect(notification_service).to receive(:"new_#{entity_type}_member").with(member)
- member.save
+ member.save!
end
end
diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb
index dda5fa37b26..94c52bdaaa6 100644
--- a/spec/support/shared_examples/models/mentionable_shared_examples.rb
+++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb
@@ -162,7 +162,7 @@ RSpec.shared_examples 'an editable mentionable' do
end
it 'creates new cross-reference notes when the mentionable text is edited' do
- subject.save
+ subject.save!
subject.create_cross_references!
new_text = <<-MSG.strip_heredoc
@@ -270,7 +270,7 @@ RSpec.shared_examples 'mentions in notes' do |mentionable_type|
let!(:mentionable) { note.noteable }
before do
- note.update(note: note_desc)
+ note.update!(note: note_desc)
note.store_mentions!
add_member(user)
end
@@ -292,7 +292,7 @@ RSpec.shared_examples 'load mentions from DB' do |mentionable_type|
let_it_be(:note_desc) { "#{mentioned_user.to_reference} and #{group.to_reference(full: true)} and @all" }
before do
- note.update(note: note_desc)
+ note.update!(note: note_desc)
note.store_mentions!
add_member(user)
end
@@ -305,7 +305,7 @@ RSpec.shared_examples 'load mentions from DB' do |mentionable_type|
mentioned_projects_ids: user_mention.mentioned_projects_ids.to_a << non_existing_record_id,
mentioned_groups_ids: user_mention.mentioned_groups_ids.to_a << non_existing_record_id
}
- user_mention.update(mention_ids)
+ user_mention.update!(mention_ids)
end
it 'filters out inexistent mentions' do
@@ -328,7 +328,7 @@ RSpec.shared_examples 'load mentions from DB' do |mentionable_type|
mentioned_projects_ids: user_mention.mentioned_projects_ids.to_a << private_project.id,
mentioned_groups_ids: user_mention.mentioned_groups_ids.to_a << private_group.id
}
- user_mention.update(mention_ids)
+ user_mention.update!(mention_ids)
add_member(mega_user)
private_project.add_developer(mega_user)
diff --git a/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb b/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb
index 7bbc0c5a364..7701ab42007 100644
--- a/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb
+++ b/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb
@@ -53,7 +53,7 @@ RSpec.shared_examples 'latest successful build for sha or ref' do
let(:build_name) { pending_build.name }
before do
- pipeline.update(status: 'pending')
+ pipeline.update!(status: 'pending')
end
it 'returns empty relation' do
diff --git a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb
index e4668926d74..d1437244082 100644
--- a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb
+++ b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb
@@ -1,9 +1,25 @@
# frozen_string_literal: true
+# Notes for implementing classes:
+#
+# The following let bindings should be defined:
+# - `factory`: A symbol naming a factory to use to create items
+# - `default_params`: A HashMap of factory parameters to pass to the factory.
+#
+# The `default_params` should include the relative parent, so that any item
+# created with these parameters passed to the `factory` will be considered in
+# the same set of items relative to each other.
+#
+# For the purposes of efficiency, it is a good idea to bind the parent in
+# `let_it_be`, so that it is re-used across examples, but be careful that it
+# does not have any other children - it should only be used within this set of
+# shared examples.
RSpec.shared_examples 'a class that supports relative positioning' do
let(:item1) { create_item }
let(:item2) { create_item }
- let(:new_item) { create_item }
+ let(:new_item) { create_item(relative_position: nil) }
+
+ let(:set_size) { RelativePositioning.mover.context(item1).scoped_items.count }
def create_item(params = {})
create(factory, params.merge(default_params))
@@ -17,6 +33,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do
describe '.move_nulls_to_end' do
let(:item3) { create_item }
+ let(:sibling_query) { item1.class.relative_positioning_query_base(item1) }
it 'moves items with null relative_position to the end' do
item1.update!(relative_position: 1000)
@@ -28,10 +45,9 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(items.sort_by(&:relative_position)).to eq(items)
expect(item1.relative_position).to be(1000)
- expect(item1.prev_relative_position).to be_nil
- expect(item1.next_relative_position).to eq(item2.relative_position)
- expect(item2.next_relative_position).to eq(item3.relative_position)
- expect(item3.next_relative_position).to be_nil
+
+ expect(sibling_query.where(relative_position: nil)).not_to exist
+ expect(sibling_query.reorder(:relative_position, :id)).to eq([item1, item2, item3])
end
it 'preserves relative position' do
@@ -70,6 +86,37 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(items.sort_by(&:relative_position)).to eq(items)
end
+ it 'manages to move nulls to the end even if there is not enough space' do
+ run = run_at_end(20).to_a
+ bunch_a = create_items_with_positions(run[0..18])
+ bunch_b = create_items_with_positions([run.last])
+
+ nils = create_items_with_positions([nil] * 4)
+ described_class.move_nulls_to_end(nils)
+
+ items = [*bunch_a, *bunch_b, *nils]
+ items.each(&:reset)
+
+ expect(items.map(&:relative_position)).to all(be_valid_position)
+ expect(items.reverse.sort_by(&:relative_position)).to eq(items)
+ end
+
+ it 'manages to move nulls to the end, stacking if we cannot create enough space' do
+ run = run_at_end(40).to_a
+ bunch = create_items_with_positions(run.select(&:even?))
+
+ nils = create_items_with_positions([nil] * 20)
+ described_class.move_nulls_to_end(nils)
+
+ items = [*bunch, *nils]
+ items.each(&:reset)
+
+ expect(items.map(&:relative_position)).to all(be_valid_position)
+ expect(bunch.reverse.sort_by(&:relative_position)).to eq(bunch)
+ expect(nils.reverse.sort_by(&:relative_position)).not_to eq(nils)
+ expect(bunch.map(&:relative_position)).to all(be < nils.map(&:relative_position).min)
+ end
+
it 'does not have an N+1 issue' do
create_items_with_positions(10..12)
@@ -89,6 +136,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do
describe '.move_nulls_to_start' do
let(:item3) { create_item }
+ let(:sibling_query) { item1.class.relative_positioning_query_base(item1) }
it 'moves items with null relative_position to the start' do
item1.update!(relative_position: nil)
@@ -100,10 +148,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
items.map(&:reload)
expect(items.sort_by(&:relative_position)).to eq(items)
- expect(item1.prev_relative_position).to eq nil
- expect(item1.next_relative_position).to eq item2.relative_position
- expect(item2.next_relative_position).to eq item3.relative_position
- expect(item3.next_relative_position).to eq nil
+ expect(sibling_query.where(relative_position: nil)).not_to exist
+ expect(sibling_query.reorder(:relative_position, :id)).to eq(items)
expect(item3.relative_position).to be(1000)
end
@@ -130,193 +176,36 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(described_class.move_nulls_to_start([item1])).to be(0)
expect(item1.reload.relative_position).to be(1)
end
- end
-
- describe '#max_relative_position' do
- it 'returns maximum position' do
- expect(item1.max_relative_position).to eq item2.relative_position
- end
- end
-
- describe '#prev_relative_position' do
- it 'returns previous position if there is an item above' do
- item1.update!(relative_position: 5)
- item2.update!(relative_position: 15)
-
- expect(item2.prev_relative_position).to eq item1.relative_position
- end
-
- it 'returns nil if there is no item above' do
- expect(item1.prev_relative_position).to eq nil
- end
- end
-
- describe '#next_relative_position' do
- it 'returns next position if there is an item below' do
- item1.update!(relative_position: 5)
- item2.update!(relative_position: 15)
-
- expect(item1.next_relative_position).to eq item2.relative_position
- end
-
- it 'returns nil if there is no item below' do
- expect(item2.next_relative_position).to eq nil
- end
- end
-
- describe '#find_next_gap_before' do
- context 'there is no gap' do
- let(:items) { create_items_with_positions(run_at_start) }
-
- it 'returns nil' do
- items.each do |item|
- expect(item.send(:find_next_gap_before)).to be_nil
- end
- end
- end
-
- context 'there is a sequence ending at MAX_POSITION' do
- let(:items) { create_items_with_positions(run_at_end) }
-
- let(:gaps) do
- items.map { |item| item.send(:find_next_gap_before) }
- end
-
- it 'can find the gap at the start for any item in the sequence' do
- gap = { start: items.first.relative_position, end: RelativePositioning::MIN_POSITION }
-
- expect(gaps).to all(eq(gap))
- end
-
- it 'respects lower bounds' do
- gap = { start: items.first.relative_position, end: 10 }
- new_item.update!(relative_position: 10)
-
- expect(gaps).to all(eq(gap))
- end
- end
-
- specify do
- item1.update!(relative_position: 5)
-
- (0..10).each do |pos|
- item2.update!(relative_position: pos)
-
- gap = item2.send(:find_next_gap_before)
-
- expect(gap[:start]).to be <= item2.relative_position
- expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP
- expect(gap[:start]).to be_valid_position
- expect(gap[:end]).to be_valid_position
- end
- end
-
- it 'deals with there not being any items to the left' do
- create_items_with_positions([1, 2, 3])
- new_item.update!(relative_position: 0)
-
- expect(new_item.send(:find_next_gap_before)).to eq(start: 0, end: RelativePositioning::MIN_POSITION)
- end
-
- it 'finds the next gap to the left, skipping adjacent values' do
- create_items_with_positions([1, 9, 10])
- new_item.update!(relative_position: 11)
-
- expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 1)
- end
-
- it 'finds the next gap to the left' do
- create_items_with_positions([2, 10])
-
- new_item.update!(relative_position: 15)
- expect(new_item.send(:find_next_gap_before)).to eq(start: 15, end: 10)
-
- new_item.update!(relative_position: 11)
- expect(new_item.send(:find_next_gap_before)).to eq(start: 10, end: 2)
-
- new_item.update!(relative_position: 9)
- expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 2)
-
- new_item.update!(relative_position: 5)
- expect(new_item.send(:find_next_gap_before)).to eq(start: 5, end: 2)
- end
- end
-
- describe '#find_next_gap_after' do
- context 'there is no gap' do
- let(:items) { create_items_with_positions(run_at_end) }
- it 'returns nil' do
- items.each do |item|
- expect(item.send(:find_next_gap_after)).to be_nil
- end
- end
- end
-
- context 'there is a sequence starting at MIN_POSITION' do
- let(:items) { create_items_with_positions(run_at_start) }
-
- let(:gaps) do
- items.map { |item| item.send(:find_next_gap_after) }
- end
-
- it 'can find the gap at the end for any item in the sequence' do
- gap = { start: items.last.relative_position, end: RelativePositioning::MAX_POSITION }
-
- expect(gaps).to all(eq(gap))
- end
+ it 'manages to move nulls to the start even if there is not enough space' do
+ run = run_at_start(20).to_a
+ bunch_a = create_items_with_positions([run.first])
+ bunch_b = create_items_with_positions(run[2..])
- it 'respects upper bounds' do
- gap = { start: items.last.relative_position, end: 10 }
- new_item.update!(relative_position: 10)
+ nils = create_items_with_positions([nil, nil, nil, nil])
+ described_class.move_nulls_to_start(nils)
- expect(gaps).to all(eq(gap))
- end
- end
-
- specify do
- item1.update!(relative_position: 5)
-
- (0..10).each do |pos|
- item2.update!(relative_position: pos)
-
- gap = item2.send(:find_next_gap_after)
-
- expect(gap[:start]).to be >= item2.relative_position
- expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP
- expect(gap[:start]).to be_valid_position
- expect(gap[:end]).to be_valid_position
- end
- end
-
- it 'deals with there not being any items to the right' do
- create_items_with_positions([1, 2, 3])
- new_item.update!(relative_position: 5)
-
- expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: RelativePositioning::MAX_POSITION)
- end
-
- it 'finds the next gap to the right, skipping adjacent values' do
- create_items_with_positions([1, 2, 10])
- new_item.update!(relative_position: 0)
+ items = [*nils, *bunch_a, *bunch_b]
+ items.each(&:reset)
- expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10)
+ expect(items.map(&:relative_position)).to all(be_valid_position)
+ expect(items.reverse.sort_by(&:relative_position)).to eq(items)
end
- it 'finds the next gap to the right' do
- create_items_with_positions([2, 10])
+ it 'manages to move nulls to the end, stacking if we cannot create enough space' do
+ run = run_at_start(40).to_a
+ bunch = create_items_with_positions(run.select(&:even?))
- new_item.update!(relative_position: 0)
- expect(new_item.send(:find_next_gap_after)).to eq(start: 0, end: 2)
+ nils = create_items_with_positions([nil].cycle.take(20))
+ described_class.move_nulls_to_start(nils)
- new_item.update!(relative_position: 1)
- expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10)
-
- new_item.update!(relative_position: 3)
- expect(new_item.send(:find_next_gap_after)).to eq(start: 3, end: 10)
+ items = [*nils, *bunch]
+ items.each(&:reset)
- new_item.update!(relative_position: 5)
- expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: 10)
+ expect(items.map(&:relative_position)).to all(be_valid_position)
+ expect(bunch.reverse.sort_by(&:relative_position)).to eq(bunch)
+ expect(nils.reverse.sort_by(&:relative_position)).not_to eq(nils)
+ expect(bunch.map(&:relative_position)).to all(be > nils.map(&:relative_position).max)
end
end
@@ -384,36 +273,39 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
context 'leap-frogging to the left' do
+ let(:item3) { create(factory, default_params) }
+ let(:start) { RelativePositioning::START_POSITION }
+
before do
- start = RelativePositioning::START_POSITION
item1.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 0)
item2.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 1)
item3.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 2)
end
- let(:item3) { create(factory, default_params) }
+ def leap_frog
+ a, b = [item1.reset, item2.reset].sort_by(&:relative_position)
- def leap_frog(steps)
- a = item1
- b = item2
-
- steps.times do |i|
- a.move_before(b)
- a.save!
- a, b = b, a
- end
+ b.move_before(a)
+ b.save!
end
- it 'can leap-frog STEPS - 1 times before needing to rebalance' do
- # This is less efficient than going right, due to the flooring of
- # integer division
- expect { leap_frog(RelativePositioning::STEPS - 1) }
- .not_to change { item3.reload.relative_position }
+ it 'can leap-frog STEPS times before needing to rebalance' do
+ expect { RelativePositioning::STEPS.times { leap_frog } }
+ .to change { item3.reload.relative_position }.by(0)
+ .and change { item1.reload.relative_position }.by(be < 0)
+ .and change { item2.reload.relative_position }.by(be < 0)
+
+ expect { leap_frog }
+ .to change { item3.reload.relative_position }.by(be < 0)
end
- it 'rebalances after leap-frogging STEPS times' do
- expect { leap_frog(RelativePositioning::STEPS) }
- .to change { item3.reload.relative_position }
+ context 'there is no space to the left after moving STEPS times' do
+ let(:start) { RelativePositioning::MIN_POSITION + (2 * RelativePositioning::IDEAL_DISTANCE) }
+
+ it 'rebalances to the right' do
+ expect { RelativePositioning::STEPS.succ.times { leap_frog } }
+ .not_to change { item3.reload.relative_position }
+ end
end
end
end
@@ -476,25 +368,25 @@ RSpec.shared_examples 'a class that supports relative positioning' do
let(:item3) { create(factory, default_params) }
- def leap_frog(steps)
- a = item1
- b = item2
+ def leap_frog
+ a, b = [item1.reset, item2.reset].sort_by(&:relative_position)
- steps.times do |i|
- a.move_after(b)
- a.save!
- a, b = b, a
- end
+ a.move_after(b)
+ a.save!
end
- it 'can leap-frog STEPS times before needing to rebalance' do
- expect { leap_frog(RelativePositioning::STEPS) }
- .not_to change { item3.reload.relative_position }
- end
+ it 'rebalances after STEPS jumps' do
+ RelativePositioning::STEPS.pred.times do
+ expect { leap_frog }
+ .to change { item3.reload.relative_position }.by(0)
+ .and change { item1.reset.relative_position }.by(be >= 0)
+ .and change { item2.reset.relative_position }.by(be >= 0)
+ end
- it 'rebalances after leap-frogging STEPS+1 times' do
- expect { leap_frog(RelativePositioning::STEPS + 1) }
- .to change { item3.reload.relative_position }
+ expect { leap_frog }
+ .to change { item3.reload.relative_position }.by(0)
+ .and change { item1.reset.relative_position }.by(be < 0)
+ .and change { item2.reset.relative_position }.by(be < 0)
end
end
end
@@ -506,12 +398,26 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
end
+ it 'places items at most IDEAL_DISTANCE from the start when the range is open' do
+ n = set_size
+
+ expect([item1, item2].map(&:relative_position)).to all(be >= (RelativePositioning::START_POSITION - (n * RelativePositioning::IDEAL_DISTANCE)))
+ end
+
it 'moves item to the end' do
new_item.move_to_start
expect(new_item.relative_position).to be < item2.relative_position
end
+ it 'positions the item at MIN_POSITION when there is only one space left' do
+ item2.update!(relative_position: RelativePositioning::MIN_POSITION + 1)
+
+ new_item.move_to_start
+
+ expect(new_item.relative_position).to eq RelativePositioning::MIN_POSITION
+ end
+
it 'rebalances when there is already an item at the MIN_POSITION' do
item2.update!(relative_position: RelativePositioning::MIN_POSITION)
@@ -543,12 +449,26 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
end
+ it 'places items at most IDEAL_DISTANCE from the start when the range is open' do
+ n = set_size
+
+ expect([item1, item2].map(&:relative_position)).to all(be <= (RelativePositioning::START_POSITION + (n * RelativePositioning::IDEAL_DISTANCE)))
+ end
+
it 'moves item to the end' do
new_item.move_to_end
expect(new_item.relative_position).to be > item2.relative_position
end
+ it 'positions the item at MAX_POSITION when there is only one space left' do
+ item2.update!(relative_position: RelativePositioning::MAX_POSITION - 1)
+
+ new_item.move_to_end
+
+ expect(new_item.relative_position).to eq RelativePositioning::MAX_POSITION
+ end
+
it 'rebalances when there is already an item at the MAX_POSITION' do
item2.update!(relative_position: RelativePositioning::MAX_POSITION)
@@ -712,63 +632,6 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
end
- describe '#move_sequence_before' do
- it 'moves the whole sequence of items to the middle of the nearest gap' do
- items = create_items_with_positions([90, 100, 101, 102])
-
- items.last.move_sequence_before
- items.last.save!
-
- positions = items.map { |item| item.reload.relative_position }
- expect(positions).to eq([90, 95, 96, 102])
- end
-
- it 'raises an error if there is no space' do
- items = create_items_with_positions(run_at_start)
-
- expect { items.last.move_sequence_before }.to raise_error(RelativePositioning::NoSpaceLeft)
- end
-
- it 'finds a gap if there are unused positions' do
- items = create_items_with_positions([100, 101, 102])
-
- items.last.move_sequence_before
- items.last.save!
-
- positions = items.map { |item| item.reload.relative_position }
-
- expect(positions.last - positions.second).to be > RelativePositioning::MIN_GAP
- end
- end
-
- describe '#move_sequence_after' do
- it 'moves the whole sequence of items to the middle of the nearest gap' do
- items = create_items_with_positions([100, 101, 102, 110])
-
- items.first.move_sequence_after
- items.first.save!
-
- positions = items.map { |item| item.reload.relative_position }
- expect(positions).to eq([100, 105, 106, 110])
- end
-
- it 'finds a gap if there are unused positions' do
- items = create_items_with_positions([100, 101, 102])
-
- items.first.move_sequence_after
- items.first.save!
-
- positions = items.map { |item| item.reload.relative_position }
- expect(positions.second - positions.first).to be > RelativePositioning::MIN_GAP
- end
-
- it 'raises an error if there is no space' do
- items = create_items_with_positions(run_at_end)
-
- expect { items.first.move_sequence_after }.to raise_error(RelativePositioning::NoSpaceLeft)
- end
- end
-
def be_valid_position
be_between(RelativePositioning::MIN_POSITION, RelativePositioning::MAX_POSITION)
end
diff --git a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb
index a5228c43f6f..a1867e1ce39 100644
--- a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb
+++ b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb
@@ -164,7 +164,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
context "event channels" do
it "uses the right channel for push event" do
- chat_service.update(push_channel: "random")
+ chat_service.update!(push_channel: "random")
expect(Slack::Messenger).to execute_with_options(channel: ['random'])
@@ -172,7 +172,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
end
it "uses the right channel for merge request event" do
- chat_service.update(merge_request_channel: "random")
+ chat_service.update!(merge_request_channel: "random")
expect(Slack::Messenger).to execute_with_options(channel: ['random'])
@@ -180,7 +180,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
end
it "uses the right channel for issue event" do
- chat_service.update(issue_channel: "random")
+ chat_service.update!(issue_channel: "random")
expect(Slack::Messenger).to execute_with_options(channel: ['random'])
@@ -191,7 +191,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
let(:issue_service_options) { { title: 'Secret', confidential: true } }
it "uses confidential issue channel" do
- chat_service.update(confidential_issue_channel: 'confidential')
+ chat_service.update!(confidential_issue_channel: 'confidential')
expect(Slack::Messenger).to execute_with_options(channel: ['confidential'])
@@ -199,7 +199,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
end
it 'falls back to issue channel' do
- chat_service.update(issue_channel: 'fallback_channel')
+ chat_service.update!(issue_channel: 'fallback_channel')
expect(Slack::Messenger).to execute_with_options(channel: ['fallback_channel'])
@@ -208,7 +208,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
end
it "uses the right channel for wiki event" do
- chat_service.update(wiki_page_channel: "random")
+ chat_service.update!(wiki_page_channel: "random")
expect(Slack::Messenger).to execute_with_options(channel: ['random'])
@@ -221,7 +221,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
end
it "uses the right channel" do
- chat_service.update(note_channel: "random")
+ chat_service.update!(note_channel: "random")
note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
@@ -236,7 +236,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
end
it "uses confidential channel" do
- chat_service.update(confidential_note_channel: "confidential")
+ chat_service.update!(confidential_note_channel: "confidential")
note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
@@ -246,7 +246,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
end
it 'falls back to note channel' do
- chat_service.update(note_channel: "fallback_channel")
+ chat_service.update!(note_channel: "fallback_channel")
note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
diff --git a/spec/support/shared_examples/models/throttled_touch_shared_examples.rb b/spec/support/shared_examples/models/throttled_touch_shared_examples.rb
index fc4f6053bb9..14b851d2828 100644
--- a/spec/support/shared_examples/models/throttled_touch_shared_examples.rb
+++ b/spec/support/shared_examples/models/throttled_touch_shared_examples.rb
@@ -3,7 +3,7 @@
RSpec.shared_examples 'throttled touch' do
describe '#touch' do
it 'updates the updated_at timestamp' do
- Timecop.freeze do
+ freeze_time do
subject.touch
expect(subject.updated_at).to be_like_time(Time.zone.now)
end
diff --git a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb
index 7f0da19996e..557025569b8 100644
--- a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb
+++ b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb
@@ -105,7 +105,7 @@ RSpec.shared_examples 'UpdateProjectStatistics' do
expect(ProjectStatistics)
.not_to receive(:increment_statistic)
- project.update(pending_delete: true)
+ project.update!(pending_delete: true)
project.destroy!
end
@@ -113,7 +113,7 @@ RSpec.shared_examples 'UpdateProjectStatistics' do
expect(Namespaces::ScheduleAggregationWorker)
.not_to receive(:perform_async)
- project.update(pending_delete: true)
+ project.update!(pending_delete: true)
project.destroy!
end
end
diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb
index a881d5f036c..b87f7fe97e1 100644
--- a/spec/support/shared_examples/models/wiki_shared_examples.rb
+++ b/spec/support/shared_examples/models/wiki_shared_examples.rb
@@ -322,8 +322,8 @@ RSpec.shared_examples 'wiki model' do
expect(commit.committer_email).to eq(user.commit_email)
end
- it 'updates container activity' do
- expect(subject).to receive(:update_container_activity)
+ it 'runs after_wiki_activity callbacks' do
+ expect(subject).to receive(:after_wiki_activity)
subject.create_page('Test Page', 'This is content')
end
@@ -363,10 +363,10 @@ RSpec.shared_examples 'wiki model' do
expect(commit.committer_email).to eq(user.commit_email)
end
- it 'updates container activity' do
+ it 'runs after_wiki_activity callbacks' do
page
- expect(subject).to receive(:update_container_activity)
+ expect(subject).to receive(:after_wiki_activity)
update_page
end
@@ -389,10 +389,10 @@ RSpec.shared_examples 'wiki model' do
expect(commit.committer_email).to eq(user.commit_email)
end
- it 'updates container activity' do
+ it 'runs after_wiki_activity callbacks' do
page
- expect(subject).to receive(:update_container_activity)
+ expect(subject).to receive(:after_wiki_activity)
subject.delete_page(page)
end
diff --git a/spec/support/shared_examples/models/with_uploads_shared_examples.rb b/spec/support/shared_examples/models/with_uploads_shared_examples.rb
index f2a4d9919b7..0c930ec1fce 100644
--- a/spec/support/shared_examples/models/with_uploads_shared_examples.rb
+++ b/spec/support/shared_examples/models/with_uploads_shared_examples.rb
@@ -12,7 +12,7 @@ RSpec.shared_examples 'model with uploads' do |supports_fileuploads|
it 'deletes remote uploads' do
expect_any_instance_of(CarrierWave::Storage::Fog::File).to receive(:delete).and_call_original
- expect { model_object.destroy }.to change { Upload.count }.by(-1)
+ expect { model_object.destroy! }.to change { Upload.count }.by(-1)
end
end
@@ -21,13 +21,13 @@ RSpec.shared_examples 'model with uploads' do |supports_fileuploads|
let!(:uploads) { create_list(:upload, 2, uploader: FileUploader, model: model_object) }
it 'deletes any FileUploader uploads which are not mounted' do
- expect { model_object.destroy }.to change { Upload.count }.by(-3)
+ expect { model_object.destroy! }.to change { Upload.count }.by(-3)
end
it 'deletes local files' do
expect_any_instance_of(Uploads::Local).to receive(:delete_keys).with(uploads.map(&:absolute_path))
- model_object.destroy
+ model_object.destroy!
end
end
@@ -35,14 +35,14 @@ RSpec.shared_examples 'model with uploads' do |supports_fileuploads|
let!(:uploads) { create_list(:upload, 2, :object_storage, uploader: FileUploader, model: model_object) }
it 'deletes any FileUploader uploads which are not mounted' do
- expect { model_object.destroy }.to change { Upload.count }.by(-3)
+ expect { model_object.destroy! }.to change { Upload.count }.by(-3)
end
it 'deletes remote files' do
expected_array = array_including(*uploads.map(&:path))
expect_any_instance_of(Uploads::Fog).to receive(:delete_keys).with(expected_array)
- model_object.destroy
+ model_object.destroy!
end
end
end
diff --git a/spec/support/shared_examples/path_extraction_shared_examples.rb b/spec/support/shared_examples/path_extraction_shared_examples.rb
index ff55bc9a490..39c7c1f2a94 100644
--- a/spec/support/shared_examples/path_extraction_shared_examples.rb
+++ b/spec/support/shared_examples/path_extraction_shared_examples.rb
@@ -146,20 +146,6 @@ RSpec.shared_examples 'extracts refs' do
expect(extract_ref('release/app/doc/README.md')).to eq(['release/app', 'doc/README.md'])
end
-
- context 'when the extracts_path_optimization feature flag is disabled' do
- before do
- stub_feature_flags(extracts_path_optimization: false)
- end
-
- it 'always fetches all ref names' do
- expect(self).to receive(:ref_names).and_call_original
- expect(container.repository).not_to receive(:branch_names_include?)
- expect(container.repository).not_to receive(:tag_names_include?)
-
- expect(extract_ref('v1.0.0/doc/README.md')).to eq(['v1.0.0', 'doc/README.md'])
- end
- end
end
context 'when the repository has ambiguous refs' do
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 d8476f5dcc2..d05e5eb9120 100644
--- a/spec/support/shared_examples/policies/project_policy_shared_examples.rb
+++ b/spec/support/shared_examples/policies/project_policy_shared_examples.rb
@@ -59,8 +59,7 @@ RSpec.shared_examples 'project policies as anonymous' do
let(:project) { create(:project, :public, namespace: group) }
let(:user_permissions) { [:create_merge_request_in, :create_project, :create_issue, :create_note, :upload_file, :award_emoji] }
let(:anonymous_permissions) { guest_permissions - user_permissions }
-
- subject { described_class.new(nil, project) }
+ let(:current_user) { anonymous }
before do
create(:group_member, :invited, group: group)
@@ -78,9 +77,8 @@ RSpec.shared_examples 'project policies as anonymous' do
end
context 'abilities for non-public projects' do
- let(:project) { create(:project, namespace: owner.namespace) }
-
- subject { described_class.new(nil, project) }
+ let(:project) { private_project }
+ let(:current_user) { anonymous }
it { is_expected.to be_banned }
end
@@ -109,10 +107,10 @@ RSpec.shared_examples 'deploy token does not get confused with user' do
end
RSpec.shared_examples 'project policies as guest' do
- subject { described_class.new(guest, project) }
-
context 'abilities for non-public projects' do
- let(:project) { create(:project, namespace: owner.namespace) }
+ let(:project) { private_project }
+ let(:current_user) { guest }
+
let(:reporter_public_build_permissions) do
reporter_permissions - [:read_build, :read_pipeline]
end
@@ -167,9 +165,8 @@ end
RSpec.shared_examples 'project policies as reporter' do
context 'abilities for non-public projects' do
- let(:project) { create(:project, namespace: owner.namespace) }
-
- subject { described_class.new(reporter, project) }
+ let(:project) { private_project }
+ let(:current_user) { reporter }
it do
expect_allowed(*guest_permissions)
@@ -192,9 +189,8 @@ end
RSpec.shared_examples 'project policies as developer' do
context 'abilities for non-public projects' do
- let(:project) { create(:project, namespace: owner.namespace) }
-
- subject { described_class.new(developer, project) }
+ let(:project) { private_project }
+ let(:current_user) { developer }
it do
expect_allowed(*guest_permissions)
@@ -217,9 +213,8 @@ end
RSpec.shared_examples 'project policies as maintainer' do
context 'abilities for non-public projects' do
- let(:project) { create(:project, namespace: owner.namespace) }
-
- subject { described_class.new(maintainer, project) }
+ let(:project) { private_project }
+ let(:current_user) { maintainer }
it do
expect_allowed(*guest_permissions)
@@ -242,9 +237,8 @@ end
RSpec.shared_examples 'project policies as owner' do
context 'abilities for non-public projects' do
- let(:project) { create(:project, namespace: owner.namespace) }
-
- subject { described_class.new(owner, project) }
+ let(:project) { private_project }
+ let(:current_user) { owner }
it do
expect_allowed(*guest_permissions)
@@ -267,9 +261,8 @@ end
RSpec.shared_examples 'project policies as admin with admin mode' do
context 'abilities for non-public projects', :enable_admin_mode do
- let(:project) { create(:project, namespace: owner.namespace) }
-
- subject { described_class.new(admin, project) }
+ let(:project) { private_project }
+ let(:current_user) { admin }
it do
expect_allowed(*guest_permissions)
@@ -316,9 +309,8 @@ end
RSpec.shared_examples 'project policies as admin without admin mode' do
context 'abilities for non-public projects' do
- let(:project) { create(:project, namespace: owner.namespace) }
-
- subject { described_class.new(admin, project) }
+ let(:project) { private_project }
+ let(:current_user) { admin }
it { is_expected.to be_banned }
diff --git a/spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb b/spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb
index 9bfd1e6faa0..e94d29febfb 100644
--- a/spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb
@@ -1,12 +1,13 @@
# frozen_string_literal: true
-# Shared examples to that test code that creates AwardEmoji also mark Todos
-# as done.
+# Shared examples to test that the code that creates AwardEmoji also marks
+# ToDos as done.
#
# The examples expect these to be defined in the calling spec:
# - `subject` the callable code that executes the creation of an AwardEmoji
# - `user`
# - `project`
+#
RSpec.shared_examples 'creating award emojis marks Todos as done' do
using RSpec::Parameterized::TableSyntax
@@ -22,7 +23,7 @@ RSpec.shared_examples 'creating award emojis marks Todos as done' do
with_them do
let(:project) { awardable.project }
- let(:awardable) { create(type) }
+ let(:awardable) { create(type) } # rubocop:disable Rails/SaveBang
let!(:todo) { create(:todo, target: awardable, project: project, user: user) }
specify do
diff --git a/spec/support/shared_examples/requests/api/boards_shared_examples.rb b/spec/support/shared_examples/requests/api/boards_shared_examples.rb
index 20b0f4f0dd2..0096aab55e3 100644
--- a/spec/support/shared_examples/requests/api/boards_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/boards_shared_examples.rb
@@ -169,7 +169,7 @@ RSpec.shared_examples 'group and project boards' do |route_definition, ee = fals
before do
if board_parent.try(:namespace)
- board_parent.update(namespace: owner.namespace)
+ board_parent.update!(namespace: owner.namespace)
else
board.resource_parent.add_owner(owner)
end
diff --git a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb
index 09743c20fba..5c122b4b5d6 100644
--- a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb
@@ -16,8 +16,11 @@ RSpec.shared_examples 'Composer package index' do |user_type, status, add_member
subject
expect(response).to have_gitlab_http_status(status)
- expect(response).to match_response_schema('public_api/v4/packages/composer/index')
- expect(json_response).to eq presenter.root
+
+ if status == :success
+ expect(response).to match_response_schema('public_api/v4/packages/composer/index')
+ expect(json_response).to eq presenter.root
+ end
end
end
end
@@ -87,13 +90,22 @@ RSpec.shared_examples 'process Composer api request' do |user_type, status, add_
end
end
-RSpec.shared_context 'Composer auth headers' do |user_role, user_token|
+RSpec.shared_context 'Composer auth headers' do |user_role, user_token, auth_method = :token|
let(:token) { user_token ? personal_access_token.token : 'wrong' }
- let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) }
+
+ let(:headers) do
+ if user_role == :anonymous
+ {}
+ elsif auth_method == :token
+ { 'Private-Token' => token }
+ else
+ basic_auth_header(user.username, token)
+ end
+ end
end
-RSpec.shared_context 'Composer api project access' do |project_visibility_level, user_role, user_token|
- include_context 'Composer auth headers', user_role, user_token do
+RSpec.shared_context 'Composer api project access' do |project_visibility_level, user_role, user_token, auth_method|
+ include_context 'Composer auth headers', user_role, user_token, auth_method do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false))
end
diff --git a/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb
new file mode 100644
index 00000000000..c56290a0aa9
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb
@@ -0,0 +1,843 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'conan ping endpoint' do
+ it 'responds with 401 Unauthorized when no token provided' do
+ get api(url)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'responds with 200 OK when valid token is provided' do
+ jwt = build_jwt(personal_access_token)
+ get api(url), headers: build_token_auth_header(jwt.encoded)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
+ end
+
+ it 'responds with 200 OK when valid job token is provided' do
+ jwt = build_jwt_from_job(job)
+ get api(url), headers: build_token_auth_header(jwt.encoded)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
+ end
+
+ it 'responds with 200 OK when valid deploy token is provided' do
+ jwt = build_jwt_from_deploy_token(deploy_token)
+ get api(url), headers: build_token_auth_header(jwt.encoded)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
+ end
+
+ it 'responds with 401 Unauthorized when invalid access token ID is provided' do
+ jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id)
+ get api(url), headers: build_token_auth_header(jwt.encoded)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'responds with 401 Unauthorized when invalid user is provided' do
+ jwt = build_jwt(personal_access_token, user_id: 12345)
+ get api(url), headers: build_token_auth_header(jwt.encoded)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do
+ jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32))
+ get api(url), headers: build_token_auth_header(jwt.encoded)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'responds with 401 Unauthorized when invalid JWT is provided' do
+ get api(url), headers: build_token_auth_header('invalid-jwt')
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ context 'packages feature disabled' do
+ it 'responds with 404 Not Found' do
+ stub_packages_setting(enabled: false)
+ get api(url)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+end
+
+RSpec.shared_examples 'conan search endpoint' do
+ before do
+ project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC)
+
+ get api(url), headers: headers, params: params
+ end
+
+ subject { json_response['results'] }
+
+ context 'returns packages with a matching name' do
+ let(:params) { { q: package.conan_recipe } }
+
+ it { is_expected.to contain_exactly(package.conan_recipe) }
+ end
+
+ context 'returns packages using a * wildcard' do
+ let(:params) { { q: "#{package.name[0, 3]}*" } }
+
+ it { is_expected.to contain_exactly(package.conan_recipe) }
+ end
+
+ context 'does not return non-matching packages' do
+ let(:params) { { q: "foo" } }
+
+ it { is_expected.to be_blank }
+ end
+end
+
+RSpec.shared_examples 'conan authenticate endpoint' do
+ subject { get api(url), headers: headers }
+
+ context 'when using invalid token' do
+ let(:auth_token) { 'invalid_token' }
+
+ it 'responds with 401' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when valid JWT access token is provided' do
+ it 'responds with 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'token has valid validity time' do
+ freeze_time do
+ subject
+
+ payload = JSONWebToken::HMACToken.decode(
+ response.body, jwt_secret).first
+ expect(payload['access_token']).to eq(personal_access_token.id)
+ expect(payload['user_id']).to eq(personal_access_token.user_id)
+
+ duration = payload['exp'] - payload['iat']
+ expect(duration).to eq(1.hour)
+ end
+ end
+ end
+
+ context 'with valid job token' do
+ let(:auth_token) { job_token }
+
+ it 'responds with 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'with valid deploy token' do
+ let(:auth_token) { deploy_token.token }
+
+ it 'responds with 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+end
+
+RSpec.shared_examples 'conan check_credentials endpoint' do
+ it 'responds with a 200 OK with PAT' do
+ get api(url), headers: headers
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'with job token' do
+ let(:auth_token) { job_token }
+
+ it 'responds with a 200 OK with job token' do
+ get api(url), headers: headers
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'with deploy token' do
+ let(:auth_token) { deploy_token.token }
+
+ it 'responds with a 200 OK with job token' do
+ get api(url), headers: headers
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ it 'responds with a 401 Unauthorized when an invalid token is used' do
+ get api(url), headers: build_token_auth_header('invalid-token')
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+end
+
+RSpec.shared_examples 'rejects invalid recipe' do
+ context 'with invalid recipe path' do
+ let(:recipe_path) { '../../foo++../..' }
+
+ it 'returns 400' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+end
+
+RSpec.shared_examples 'rejects invalid file_name' do |invalid_file_name|
+ let(:file_name) { invalid_file_name }
+
+ context 'with invalid file_name' do
+ it 'returns 400' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+end
+
+RSpec.shared_examples 'rejects recipe for invalid project' do
+ context 'with invalid project' do
+ let(:recipe_path) { 'aa/bb/cc/dd' }
+ let(:project_id) { 9999 }
+
+ it_behaves_like 'not found request'
+ end
+end
+
+RSpec.shared_examples 'empty recipe for not found package' do
+ context 'with invalid recipe url' do
+ let(:recipe_path) do
+ 'aa/bb/%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }
+ end
+
+ it 'returns not found' do
+ allow(::Packages::Conan::PackagePresenter).to receive(:new)
+ .with(
+ nil,
+ user,
+ project,
+ any_args
+ ).and_return(presenter)
+ allow(presenter).to receive(:recipe_snapshot) { {} }
+ allow(presenter).to receive(:package_snapshot) { {} }
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq("{}")
+ end
+ end
+end
+
+RSpec.shared_examples 'not selecting a package with the wrong type' do
+ context 'with a nuget package with same name and version' do
+ let(:conan_username) { ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }
+ let(:wrong_package) { create(:nuget_package, name: "wrong", version: '1.0.0', project: project) }
+ let(:recipe_path) { "#{wrong_package.name}/#{wrong_package.version}/#{conan_username}/foo" }
+
+ it 'calls the presenter with a nil package' do
+ expect(::Packages::Conan::PackagePresenter).to receive(:new)
+ .with(nil, user, project, any_args)
+
+ subject
+ end
+ end
+end
+
+RSpec.shared_examples 'recipe download_urls' do
+ let(:recipe_path) { package.conan_recipe_path }
+
+ it 'returns the download_urls for the recipe files' do
+ expected_response = {
+ 'conanfile.py' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
+ 'conanmanifest.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
+ }
+
+ allow(presenter).to receive(:recipe_urls) { expected_response }
+
+ subject
+
+ expect(json_response).to eq(expected_response)
+ end
+
+ it_behaves_like 'not selecting a package with the wrong type'
+end
+
+RSpec.shared_examples 'package download_urls' do
+ let(:recipe_path) { package.conan_recipe_path }
+
+ it 'returns the download_urls for the package files' do
+ expected_response = {
+ 'conaninfo.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt",
+ 'conanmanifest.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt",
+ 'conan_package.tgz' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz"
+ }
+
+ allow(presenter).to receive(:package_urls) { expected_response }
+
+ subject
+
+ expect(json_response).to eq(expected_response)
+ end
+
+ it_behaves_like 'not selecting a package with the wrong type'
+end
+
+RSpec.shared_examples 'rejects invalid upload_url params' do
+ context 'with unaccepted json format' do
+ let(:params) { %w[foo bar] }
+
+ it 'returns 400' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+end
+
+RSpec.shared_examples 'successful response when using Unicorn' do
+ context 'on Unicorn', :unicorn do
+ it 'returns successfully' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+end
+
+RSpec.shared_examples 'recipe snapshot endpoint' do
+ subject { get api(url), headers: headers }
+
+ it_behaves_like 'rejects invalid recipe'
+ it_behaves_like 'rejects recipe for invalid project'
+ it_behaves_like 'empty recipe for not found package'
+
+ context 'with existing package' do
+ it 'returns a hash of files with their md5 hashes' do
+ expected_response = {
+ 'conanfile.py' => 'md5hash1',
+ 'conanmanifest.txt' => 'md5hash2'
+ }
+
+ allow(presenter).to receive(:recipe_snapshot) { expected_response }
+
+ subject
+
+ expect(json_response).to eq(expected_response)
+ end
+ end
+end
+
+RSpec.shared_examples 'package snapshot endpoint' do
+ subject { get api(url), headers: headers }
+
+ it_behaves_like 'rejects invalid recipe'
+ it_behaves_like 'rejects recipe for invalid project'
+ it_behaves_like 'empty recipe for not found package'
+
+ context 'with existing package' do
+ it 'returns a hash of md5 values for the files' do
+ expected_response = {
+ 'conaninfo.txt' => "md5hash1",
+ 'conanmanifest.txt' => "md5hash2",
+ 'conan_package.tgz' => "md5hash3"
+ }
+
+ allow(presenter).to receive(:package_snapshot) { expected_response }
+
+ subject
+
+ expect(json_response).to eq(expected_response)
+ end
+ end
+end
+
+RSpec.shared_examples 'recipe download_urls endpoint' do
+ it_behaves_like 'rejects invalid recipe'
+ it_behaves_like 'rejects recipe for invalid project'
+ it_behaves_like 'recipe download_urls'
+end
+
+RSpec.shared_examples 'package download_urls endpoint' do
+ it_behaves_like 'rejects invalid recipe'
+ it_behaves_like 'rejects recipe for invalid project'
+ it_behaves_like 'package download_urls'
+end
+
+RSpec.shared_examples 'recipe upload_urls endpoint' do
+ let(:recipe_path) { package.conan_recipe_path }
+
+ let(:params) do
+ { 'conanfile.py': 24,
+ 'conanmanifest.txt': 123 }
+ end
+
+ it_behaves_like 'rejects invalid recipe'
+ it_behaves_like 'rejects invalid upload_url params'
+ it_behaves_like 'successful response when using Unicorn'
+
+ it 'returns a set of upload urls for the files requested' do
+ subject
+
+ expected_response = {
+ 'conanfile.py': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
+ 'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
+ }
+
+ expect(response.body).to eq(expected_response.to_json)
+ end
+
+ context 'with conan_sources and conan_export files' do
+ let(:params) do
+ { 'conan_sources.tgz': 345,
+ 'conan_export.tgz': 234,
+ 'conanmanifest.txt': 123 }
+ end
+
+ it 'returns upload urls for the additional files' do
+ subject
+
+ expected_response = {
+ 'conan_sources.tgz': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_sources.tgz",
+ 'conan_export.tgz': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_export.tgz",
+ 'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
+ }
+
+ expect(response.body).to eq(expected_response.to_json)
+ end
+ end
+
+ context 'with an invalid file' do
+ let(:params) do
+ { 'invalid_file.txt': 10,
+ 'conanmanifest.txt': 123 }
+ end
+
+ it 'does not return the invalid file as an upload_url' do
+ subject
+
+ expected_response = {
+ 'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
+ }
+
+ expect(response.body).to eq(expected_response.to_json)
+ end
+ end
+end
+
+RSpec.shared_examples 'package upload_urls endpoint' do
+ let(:recipe_path) { package.conan_recipe_path }
+
+ let(:params) do
+ { 'conaninfo.txt': 24,
+ 'conanmanifest.txt': 123,
+ 'conan_package.tgz': 523 }
+ end
+
+ it_behaves_like 'rejects invalid recipe'
+ it_behaves_like 'rejects invalid upload_url params'
+ it_behaves_like 'successful response when using Unicorn'
+
+ it 'returns a set of upload urls for the files requested' do
+ expected_response = {
+ 'conaninfo.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt",
+ 'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt",
+ 'conan_package.tgz': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz"
+ }
+
+ subject
+
+ expect(response.body).to eq(expected_response.to_json)
+ end
+
+ context 'with invalid files' do
+ let(:params) do
+ { 'conaninfo.txt': 24,
+ 'invalid_file.txt': 10 }
+ end
+
+ it 'returns upload urls only for the valid requested files' do
+ expected_response = {
+ 'conaninfo.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt"
+ }
+
+ subject
+
+ expect(response.body).to eq(expected_response.to_json)
+ end
+ end
+end
+
+RSpec.shared_examples 'delete package endpoint' do
+ let(:recipe_path) { package.conan_recipe_path }
+
+ it_behaves_like 'rejects invalid recipe'
+
+ it 'returns unauthorized for users without valid permission' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ context 'with delete permissions' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it_behaves_like 'a gitlab tracking event', 'API::ConanPackages', 'delete_package'
+
+ it 'deletes a package' do
+ expect { subject }.to change { Packages::Package.count }.from(2).to(1)
+ end
+ end
+end
+
+RSpec.shared_examples 'denies download with no token' do
+ context 'with no private token' do
+ let(:headers) { {} }
+
+ it 'returns 400' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+end
+
+RSpec.shared_examples 'a public project with packages' do
+ it 'returns the file' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
+end
+
+RSpec.shared_examples 'an internal project with packages' do
+ before do
+ project.team.truncate
+ project.update_column(:visibility_level, Gitlab::VisibilityLevel::INTERNAL)
+ end
+
+ it_behaves_like 'denies download with no token'
+
+ it 'returns the file' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
+end
+
+RSpec.shared_examples 'a private project with packages' do
+ before do
+ project.update_column(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it_behaves_like 'denies download with no token'
+
+ it 'returns the file' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
+
+ it 'denies download when not enough permissions' do
+ project.add_guest(user)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+end
+
+RSpec.shared_examples 'not found request' do
+ it 'returns not found' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+end
+
+RSpec.shared_examples 'recipe file download endpoint' do
+ it_behaves_like 'a public project with packages'
+ it_behaves_like 'an internal project with packages'
+ it_behaves_like 'a private project with packages'
+end
+
+RSpec.shared_examples 'package file download endpoint' do
+ it_behaves_like 'a public project with packages'
+ it_behaves_like 'an internal project with packages'
+ it_behaves_like 'a private project with packages'
+
+ context 'tracking the conan_package.tgz download' do
+ let(:package_file) { package.package_files.find_by(file_name: ::Packages::Conan::FileMetadatum::PACKAGE_BINARY) }
+
+ it_behaves_like 'a gitlab tracking event', 'API::ConanPackages', 'pull_package'
+ end
+end
+
+RSpec.shared_examples 'project not found by recipe' do
+ let(:recipe_path) { 'not/package/for/project' }
+
+ it_behaves_like 'not found request'
+end
+
+RSpec.shared_examples 'project not found by project id' do
+ let(:project_id) { 99999 }
+
+ it_behaves_like 'not found request'
+end
+
+RSpec.shared_examples 'workhorse authorize endpoint' do
+ it_behaves_like 'rejects invalid recipe'
+ it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack'
+ it_behaves_like 'workhorse authorization'
+end
+
+RSpec.shared_examples 'workhorse recipe file upload endpoint' do
+ let(:file_name) { 'conanfile.py' }
+ let(:params) { { file: temp_file(file_name) } }
+
+ subject do
+ workhorse_finalize(
+ url,
+ method: :put,
+ file_key: :file,
+ params: params,
+ headers: headers_with_token,
+ send_rewritten_field: true
+ )
+ end
+
+ it_behaves_like 'rejects invalid recipe'
+ it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack'
+ it_behaves_like 'uploads a package file'
+end
+
+RSpec.shared_examples 'workhorse package file upload endpoint' do
+ let(:file_name) { 'conaninfo.txt' }
+ let(:params) { { file: temp_file(file_name) } }
+
+ subject do
+ workhorse_finalize(
+ url,
+ method: :put,
+ file_key: :file,
+ params: params,
+ headers: headers_with_token,
+ send_rewritten_field: true
+ )
+ end
+
+ it_behaves_like 'rejects invalid recipe'
+ it_behaves_like 'rejects invalid file_name', 'conaninfo.txttest'
+ it_behaves_like 'uploads a package file'
+
+ context 'tracking the conan_package.tgz upload' do
+ let(:file_name) { ::Packages::Conan::FileMetadatum::PACKAGE_BINARY }
+
+ it_behaves_like 'a gitlab tracking event', 'API::ConanPackages', 'push_package'
+ end
+end
+
+RSpec.shared_examples 'uploads a package file' do
+ context 'file size above maximum limit' do
+ before do
+ params['file.size'] = project.actual_limits.conan_max_file_size + 1
+ end
+
+ it 'handles as a local file' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'with object storage disabled' do
+ context 'without a file from workhorse' do
+ let(:params) { { file: nil } }
+
+ it 'rejects the request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'with a file' do
+ it_behaves_like 'package workhorse uploads'
+ end
+
+ context 'without a token' do
+ it 'rejects request without a token' do
+ headers_with_token.delete('HTTP_AUTHORIZATION')
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when params from workhorse are correct' do
+ it 'creates package and stores package file' do
+ expect { subject }
+ .to change { project.packages.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ package_file = project.packages.last.package_files.reload.last
+ expect(package_file.file_name).to eq(params[:file].original_filename)
+ end
+
+ it "doesn't attempt to migrate file to object storage" do
+ expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
+
+ subject
+ end
+ end
+ end
+
+ context 'with object storage enabled' do
+ context 'and direct upload enabled' do
+ let!(:fog_connection) do
+ stub_package_file_object_storage(direct_upload: true)
+ end
+
+ let(:tmp_object) do
+ fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang
+ key: "tmp/uploads/#{file_name}",
+ body: 'content'
+ )
+ end
+
+ let(:fog_file) { fog_to_uploaded_file(tmp_object) }
+
+ ['123123', '../../123123'].each do |remote_id|
+ context "with invalid remote_id: #{remote_id}" do
+ let(:params) do
+ {
+ file: fog_file,
+ 'file.remote_id' => remote_id
+ }
+ end
+
+ it 'responds with status 403' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ context 'with valid remote_id' do
+ let(:params) do
+ {
+ file: fog_file,
+ 'file.remote_id' => file_name
+ }
+ end
+
+ it 'creates package and stores package file' do
+ expect { subject }
+ .to change { project.packages.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ package_file = project.packages.last.package_files.reload.last
+ expect(package_file.file_name).to eq(params[:file].original_filename)
+ expect(package_file.file.read).to eq('content')
+ end
+ end
+ end
+
+ it_behaves_like 'background upload schedules a file migration'
+ end
+end
+
+RSpec.shared_examples 'workhorse authorization' do
+ it 'authorizes posting package with a valid token' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
+
+ it 'rejects request without a valid token' do
+ headers_with_token['HTTP_AUTHORIZATION'] = 'foo'
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'rejects request without a valid permission' do
+ project.add_guest(user)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'rejects requests that bypassed gitlab-workhorse' do
+ headers_with_token.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ context 'when using remote storage' do
+ context 'when direct upload is enabled' do
+ before do
+ stub_package_file_object_storage(enabled: true, direct_upload: true)
+ end
+
+ it 'responds with status 200, location of package remote store and object details' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ expect(json_response).not_to have_key('TempPath')
+ expect(json_response['RemoteObject']).to have_key('ID')
+ expect(json_response['RemoteObject']).to have_key('GetURL')
+ expect(json_response['RemoteObject']).to have_key('StoreURL')
+ expect(json_response['RemoteObject']).to have_key('DeleteURL')
+ expect(json_response['RemoteObject']).not_to have_key('MultipartUpload')
+ end
+ end
+
+ context 'when direct upload is disabled' do
+ before do
+ stub_package_file_object_storage(enabled: true, direct_upload: false)
+ end
+
+ it 'handles as a local file' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ expect(json_response['TempPath']).to eq(::Packages::PackageFileUploader.workhorse_local_upload_path)
+ expect(json_response['RemoteObject']).to be_nil
+ end
+ end
+ end
+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 8cbf11b6de1..f31cbcfdec1 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
@@ -1,8 +1,8 @@
# frozen_string_literal: true
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' }
+ 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
before do
@@ -14,8 +14,7 @@ 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.size).to be 2
- expect(json_response.map { |r| r['id'] }).to contain_exactly attributable.id, other_attributable.id
+ expect(json_response.map { |r| r['id'] }).to include(attributable.id, other_attributable.id)
end
end
@@ -40,7 +39,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
get api("/#{attributable_name}", user), params: { with_custom_attributes: true }
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.size).to be 2
+ expect(json_response).not_to be_empty
expect(json_response.first).not_to include 'custom_attributes'
end
end
@@ -50,16 +49,15 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
get api("/#{attributable_name}", admin)
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.size).to be 2
+ expect(json_response).not_to be_empty
expect(json_response.first).not_to include 'custom_attributes'
- expect(json_response.second).not_to include 'custom_attributes'
end
it 'includes custom attributes if requested' do
get api("/#{attributable_name}", admin), params: { with_custom_attributes: true }
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.size).to be 2
+ expect(json_response).not_to be_empty
attributable_response = json_response.find { |r| r['id'] == attributable.id }
other_attributable_response = json_response.find { |r| r['id'] == other_attributable.id }
@@ -132,7 +130,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
end
context 'with an authorized user' do
- it'returns a single custom attribute' do
+ it 'returns a single custom attribute' do
get api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin)
expect(response).to have_gitlab_http_status(:ok)
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 48824a4b0d2..62dbac3fd4d 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
@@ -8,3 +8,42 @@ RSpec.shared_examples 'when the snippet is not found' do
it_behaves_like 'a mutation that returns top-level errors',
errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
end
+
+RSpec.shared_examples 'snippet edit usage data counters' do
+ context 'when user is sessionless' do
+ it 'does not track usage data actions' do
+ expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).not_to receive(:track_snippet_editor_edit_action)
+
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+ end
+
+ context 'when user is not sessionless' do
+ before do
+ session_id = Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d')
+ session_hash = { 'warden.user.user.key' => [[current_user.id], current_user.encrypted_password[0, 29]] }
+
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
+ end
+
+ cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
+ end
+
+ it 'tracks usage data actions', :clean_gitlab_redis_shared_state do
+ expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).to receive(:track_snippet_editor_edit_action)
+
+ post_graphql_mutation(mutation)
+ end
+
+ context 'when mutation result raises an error' do
+ it 'does not track usage data actions' do
+ mutation_vars[:title] = nil
+
+ expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).not_to receive(:track_snippet_editor_edit_action)
+
+ post_graphql_mutation(mutation)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb
index fcdc594f258..6aac51a5903 100644
--- a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb
@@ -175,7 +175,7 @@ RSpec.shared_examples 'process nuget upload' do |user_type, status, add_member =
context 'with object storage enabled' do
let(:tmp_object) do
- fog_connection.directories.new(key: 'packages').files.create(
+ fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang
key: "tmp/uploads/#{file_name}",
body: 'content'
)
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 6f4a0236b66..c9a33701161 100644
--- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb
@@ -41,3 +41,88 @@ RSpec.shared_examples 'deploy token for package uploads' do
end
end
end
+
+RSpec.shared_examples 'does not cause n^2 queries' do
+ it 'avoids N^2 database queries' do
+ # we create a package to set the baseline for expected queries from 1 package
+ create(
+ :npm_package,
+ name: "@#{project.root_namespace.path}/my-package",
+ project: project,
+ version: "0.0.1"
+ )
+
+ control = ActiveRecord::QueryRecorder.new do
+ get api(url)
+ end
+
+ 5.times do |n|
+ create(
+ :npm_package,
+ name: "@#{project.root_namespace.path}/my-package",
+ project: project,
+ version: "#{n}.0.0"
+ )
+ end
+
+ expect do
+ get api(url)
+ end.not_to exceed_query_limit(control)
+ end
+end
+
+RSpec.shared_examples 'job token for package GET requests' do
+ context 'with job token headers' do
+ let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, job.token) }
+
+ subject { get api(url), headers: headers }
+
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ project.add_developer(user)
+ end
+
+ context 'valid token' do
+ it_behaves_like 'returning response status', :success
+ end
+
+ context 'invalid token' do
+ let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, 'bar') }
+
+ it_behaves_like 'returning response status', :unauthorized
+ end
+
+ context 'invalid user' do
+ let(:headers) { basic_auth_header('foo', job.token) }
+
+ it_behaves_like 'returning response status', :unauthorized
+ end
+ end
+end
+
+RSpec.shared_examples 'job token for package uploads' do
+ context 'with job token headers' do
+ let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, job.token).merge(workhorse_header) }
+
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ project.add_developer(user)
+ end
+
+ context 'valid token' do
+ it_behaves_like 'returning response status', :success
+ end
+
+ context 'invalid token' do
+ let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, 'bar').merge(workhorse_header) }
+
+ it_behaves_like 'returning response status', :unauthorized
+ end
+
+ context 'invalid user' do
+ let(:headers) { basic_auth_header('foo', job.token).merge(workhorse_header) }
+
+ it_behaves_like 'returning response status', :unauthorized
+ end
+ end
+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 4954151b93b..715c494840e 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
@@ -58,7 +58,7 @@ RSpec.shared_examples 'PyPi package creation' do |user_type, status, add_member
context 'with object storage enabled' do
let(:tmp_object) do
- fog_connection.directories.new(key: 'packages').files.create(
+ fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang
key: "tmp/uploads/#{file_name}",
body: 'content'
)
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 cfbb84dd099..051367fbe96 100644
--- a/spec/support/shared_examples/requests/api/snippets_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/snippets_shared_examples.rb
@@ -77,3 +77,142 @@ RSpec.shared_examples 'raw snippet files' do
end
end
end
+
+RSpec.shared_examples 'snippet file updates' do
+ let(:create_action) { { action: 'create', file_path: 'foo.txt', content: 'bar' } }
+ let(:update_action) { { action: 'update', file_path: 'CHANGELOG', content: 'bar' } }
+ let(:move_action) { { action: 'move', file_path: '.old-gitattributes', previous_path: '.gitattributes' } }
+ let(:delete_action) { { action: 'delete', file_path: 'CONTRIBUTING.md' } }
+ let(:bad_file_path) { { action: 'create', file_path: '../../etc/passwd', content: 'bar' } }
+ let(:bad_previous_path) { { action: 'create', previous_path: '../../etc/passwd', file_path: 'CHANGELOG', content: 'bar' } }
+ let(:invalid_move) { { action: 'move', file_path: 'missing_previous_path.txt' } }
+
+ context 'with various snippet file changes' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:is_multi_file, :file_name, :content, :files, :status) do
+ true | nil | nil | [create_action] | :success
+ true | nil | nil | [update_action] | :success
+ true | nil | nil | [move_action] | :success
+ true | nil | nil | [delete_action] | :success
+ true | nil | nil | [create_action, update_action] | :success
+ true | 'foo.txt' | 'bar' | [create_action] | :bad_request
+ true | 'foo.txt' | 'bar' | nil | :bad_request
+ true | nil | nil | nil | :bad_request
+ true | 'foo.txt' | nil | [create_action] | :bad_request
+ true | nil | 'bar' | [create_action] | :bad_request
+ true | '' | nil | [create_action] | :bad_request
+ true | nil | '' | [create_action] | :bad_request
+ true | nil | nil | [bad_file_path] | :bad_request
+ true | nil | nil | [bad_previous_path] | :bad_request
+ true | nil | nil | [invalid_move] | :unprocessable_entity
+
+ false | 'foo.txt' | 'bar' | nil | :success
+ false | 'foo.txt' | nil | nil | :success
+ false | nil | 'bar' | nil | :success
+ false | 'foo.txt' | 'bar' | [create_action] | :bad_request
+ false | nil | nil | nil | :bad_request
+ false | nil | '' | nil | :bad_request
+ false | nil | nil | [bad_file_path] | :bad_request
+ false | nil | nil | [bad_previous_path] | :bad_request
+ end
+
+ with_them do
+ before do
+ allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(is_multi_file)
+ end
+
+ it 'has the correct response' do
+ update_params = {}.tap do |params|
+ params[:files] = files if files
+ params[:file_name] = file_name if file_name
+ params[:content] = content if content
+ end
+
+ update_snippet(params: update_params)
+
+ expect(response).to have_gitlab_http_status(status)
+ end
+ end
+
+ context 'when save fails due to a repository commit error' do
+ before do
+ allow_next_instance_of(Repository) do |instance|
+ allow(instance).to receive(:multi_action).and_raise(Gitlab::Git::CommitError)
+ end
+
+ update_snippet(params: { files: [create_action] })
+ end
+
+ it 'returns a bad request response' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'snippet non-file updates' do
+ it 'updates a snippet non-file attributes' do
+ new_description = 'New description'
+ new_title = 'New title'
+ new_visibility = 'internal'
+
+ update_snippet(params: { title: new_title, description: new_description, visibility: new_visibility })
+
+ snippet.reload
+
+ aggregate_failures do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(snippet.description).to eq(new_description)
+ expect(snippet.visibility).to eq(new_visibility)
+ expect(snippet.title).to eq(new_title)
+ end
+ end
+end
+
+RSpec.shared_examples 'snippet individual non-file updates' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:attribute, :updated_value) do
+ :description | 'new description'
+ :title | 'new title'
+ :visibility | 'private'
+ end
+
+ with_them do
+ it 'updates the attribute' do
+ params = { attribute => updated_value }
+
+ expect { update_snippet(params: params) }
+ .to change { snippet.reload.send(attribute) }.to(updated_value)
+ end
+ end
+end
+
+RSpec.shared_examples 'invalid snippet updates' do
+ it 'returns 404 for invalid snippet id' do
+ update_snippet(snippet_id: non_existing_record_id, params: { title: 'foo' })
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
+
+ it 'returns 400 for missing parameters' do
+ update_snippet
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 400 if content is blank' do
+ update_snippet(params: { content: '' })
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 400 if title is blank' do
+ update_snippet(params: { title: '' })
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq 'title is empty'
+ end
+end
diff --git a/spec/support/shared_examples/requests/snippet_shared_examples.rb b/spec/support/shared_examples/requests/snippet_shared_examples.rb
index a17163328f4..84ef7723b9b 100644
--- a/spec/support/shared_examples/requests/snippet_shared_examples.rb
+++ b/spec/support/shared_examples/requests/snippet_shared_examples.rb
@@ -2,6 +2,10 @@
RSpec.shared_examples 'update with repository actions' do
context 'when the repository exists' do
+ before do
+ allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(false)
+ end
+
it 'commits the changes to the repository' do
existing_blob = snippet.blobs.first
new_file_name = existing_blob.path + '_new'
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 1ef08de31a9..7608f1c7f8a 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
@@ -57,16 +57,6 @@ RSpec.shared_examples 'diff file entity' do
expect(subject).to include(:highlighted_diff_lines)
end
end
-
- context 'when the `single_mr_diff_view` feature is disabled' do
- before do
- stub_feature_flags(single_mr_diff_view: false)
- end
-
- it 'contains both kinds of diffs' do
- expect(subject).to include(:highlighted_diff_lines, :parallel_diff_lines)
- end
- end
end
end
diff --git a/spec/support/shared_examples/services/alert_management_shared_examples.rb b/spec/support/shared_examples/services/alert_management_shared_examples.rb
index a1354a8099b..1ae74979b7a 100644
--- a/spec/support/shared_examples/services/alert_management_shared_examples.rb
+++ b/spec/support/shared_examples/services/alert_management_shared_examples.rb
@@ -39,3 +39,41 @@ RSpec.shared_examples 'adds an alert management alert event' do
subject
end
end
+
+RSpec.shared_examples 'processes incident issues' do
+ let(:create_incident_service) { spy }
+
+ before do
+ allow_any_instance_of(AlertManagement::Alert).to receive(:execute_services)
+ end
+
+ it 'processes issues' do
+ expect(IncidentManagement::ProcessAlertWorker)
+ .to receive(:perform_async)
+ .with(nil, nil, kind_of(Integer))
+ .once
+
+ Sidekiq::Testing.inline! do
+ expect(subject).to be_success
+ end
+ end
+end
+
+RSpec.shared_examples 'does not process incident issues' do
+ it 'does not process issues' do
+ expect(IncidentManagement::ProcessAlertWorker)
+ .not_to receive(:perform_async)
+
+ expect(subject).to be_success
+ end
+end
+
+RSpec.shared_examples 'does not process incident issues due to error' do |http_status:|
+ it 'does not process issues' do
+ expect(IncidentManagement::ProcessAlertWorker)
+ .not_to receive(:perform_async)
+
+ expect(subject).to be_error
+ expect(subject.http_status).to eq(http_status)
+ end
+end
diff --git a/spec/support/shared_examples/services/common_system_notes_shared_examples.rb b/spec/support/shared_examples/services/common_system_notes_shared_examples.rb
index 20856b05de6..5b95a5753a1 100644
--- a/spec/support/shared_examples/services/common_system_notes_shared_examples.rb
+++ b/spec/support/shared_examples/services/common_system_notes_shared_examples.rb
@@ -5,7 +5,7 @@ RSpec.shared_examples 'system note creation' do |update_params, note_text|
before do
issuable.assign_attributes(update_params)
- issuable.save
+ issuable.save!
end
it 'creates 1 system note with the correct content' do
diff --git a/spec/support/shared_examples/services/incident_shared_examples.rb b/spec/support/shared_examples/services/incident_shared_examples.rb
new file mode 100644
index 00000000000..d6e79931df5
--- /dev/null
+++ b/spec/support/shared_examples/services/incident_shared_examples.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+# This shared_example requires the following variables:
+# - issue (required)
+#
+# Usage:
+#
+# it_behaves_like 'incident issue' do
+# let(:issue) { ... }
+# end
+#
+# include_examples 'incident issue'
+RSpec.shared_examples 'incident issue' do
+ let(:label_properties) { attributes_for(:label, :incident) }
+
+ it 'has incident as issue type' do
+ expect(issue.issue_type).to eq('incident')
+ end
+
+ it 'has exactly one incident label' do
+ expect(issue.labels).to be_one do |label|
+ label.slice(*label_properties.keys).symbolize_keys == label_properties
+ end
+ end
+end
+
+# This shared_example requires the following variables:
+# - issue (required)
+#
+# Usage:
+#
+# it_behaves_like 'not an incident issue' do
+# let(:issue) { ... }
+# end
+#
+# include_examples 'not an incident issue'
+RSpec.shared_examples 'not an incident issue' do
+ let(:label_properties) { attributes_for(:label, :incident) }
+
+ it 'has not incident as issue type' do
+ expect(issue.issue_type).not_to eq('incident')
+ end
+
+ it 'has not an incident label' do
+ expect(issue.labels).not_to include(have_attributes(label_properties))
+ end
+end
diff --git a/spec/support/shared_examples/services/issuable_shared_examples.rb b/spec/support/shared_examples/services/issuable_shared_examples.rb
index 9eb66e33513..47c7a1e7356 100644
--- a/spec/support/shared_examples/services/issuable_shared_examples.rb
+++ b/spec/support/shared_examples/services/issuable_shared_examples.rb
@@ -8,37 +8,6 @@ RSpec.shared_examples 'cache counters invalidator' do
end
end
-RSpec.shared_examples 'system notes for milestones' do
- def update_issuable(opts)
- issuable = try(:issue) || try(:merge_request)
- described_class.new(project, user, opts).execute(issuable)
- end
-
- context 'group milestones' do
- let(:group) { create(:group) }
- let(:group_milestone) { create(:milestone, group: group) }
-
- before do
- project.update(namespace: group)
- create(:group_member, group: group, user: user)
- end
-
- it 'creates a system note' do
- expect do
- update_issuable(milestone: group_milestone)
- end.to change { Note.system.count }.by(1)
- end
- end
-
- context 'project milestones' do
- it 'creates a system note' do
- expect do
- update_issuable(milestone: create(:milestone, project: project))
- end.to change { Note.system.count }.by(1)
- end
- end
-end
-
RSpec.shared_examples 'updating a single task' do
def update_issuable(opts)
issuable = try(:issue) || try(:merge_request)
diff --git a/spec/support/shared_examples/services/merge_request_shared_examples.rb b/spec/support/shared_examples/services/merge_request_shared_examples.rb
new file mode 100644
index 00000000000..a7032640217
--- /dev/null
+++ b/spec/support/shared_examples/services/merge_request_shared_examples.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'reviewer_ids filter' do
+ context 'filter_reviewer' do
+ let(:opts) { super().merge(reviewer_ids_param) }
+
+ context 'without reviewer_ids' do
+ let(:reviewer_ids_param) { {} }
+
+ it 'contains no reviewer_ids' do
+ expect(execute.reviewers).to eq []
+ end
+ end
+
+ context 'with reviewer_ids' do
+ let(:reviewer_ids_param) { { reviewer_ids: [reviewer1.id, reviewer2.id, reviewer3.id] } }
+
+ let(:reviewer1) { create(:user) }
+ let(:reviewer2) { create(:user) }
+ let(:reviewer3) { create(:user) }
+
+ context 'when the current user can admin the merge_request' do
+ context 'when merge_request_reviewer feature is enabled' do
+ before do
+ stub_feature_flags(merge_request_reviewer: true)
+ end
+
+ context 'with reviewers who can read the merge_request' do
+ before do
+ project.add_developer(reviewer1)
+ project.add_developer(reviewer2)
+ end
+
+ it 'contains reviewers who can read the merge_request' do
+ expect(execute.reviewers).to contain_exactly(reviewer1, reviewer2)
+ end
+ end
+ end
+
+ context 'when merge_request_reviewer feature is disabled' do
+ before do
+ stub_feature_flags(merge_request_reviewer: false)
+ end
+
+ it 'contains no reviewers' do
+ expect(execute.reviewers).to eq []
+ end
+ end
+ end
+
+ context 'when the current_user cannot admin the merge_request' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'contains no reviewers' do
+ expect(execute.reviewers).to eq []
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/packages_shared_examples.rb b/spec/support/shared_examples/services/packages_shared_examples.rb
index 45a4c2bb151..7fd59c3d963 100644
--- a/spec/support/shared_examples/services/packages_shared_examples.rb
+++ b/spec/support/shared_examples/services/packages_shared_examples.rb
@@ -14,6 +14,14 @@ RSpec.shared_examples 'assigns build to package' do
end
end
+RSpec.shared_examples 'assigns the package creator' do
+ it 'assigns the package creator' do
+ subject
+
+ expect(package.creator).to eq user
+ end
+end
+
RSpec.shared_examples 'returns packages' do |container_type, user_type|
context "for #{user_type}" do
before do
@@ -161,6 +169,7 @@ RSpec.shared_examples 'filters on each package_type' do |is_project: false|
let_it_be(:package4) { create(:nuget_package, project: project) }
let_it_be(:package5) { create(:pypi_package, project: project) }
let_it_be(:package6) { create(:composer_package, project: project) }
+ let_it_be(:package7) { create(:generic_package, project: project) }
Packages::Package.package_types.keys.each do |package_type|
context "for package type #{package_type}" do
diff --git a/spec/support/shared_examples/services/snippets_shared_examples.rb b/spec/support/shared_examples/services/snippets_shared_examples.rb
index 51a4a8b1cd9..4a08c0d4365 100644
--- a/spec/support/shared_examples/services/snippets_shared_examples.rb
+++ b/spec/support/shared_examples/services/snippets_shared_examples.rb
@@ -40,3 +40,20 @@ RSpec.shared_examples 'snippets spam check is performed' do
end
end
end
+
+shared_examples 'invalid params error response' do
+ before do
+ allow_next_instance_of(described_class) do |service|
+ allow(service).to receive(:valid_params?).and_return false
+ end
+ end
+
+ it 'responds to errors appropriately' do
+ response = subject
+
+ aggregate_failures do
+ expect(response).to be_error
+ expect(response.http_status).to eq 422
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb b/spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb
index db1b50fdf3c..ffdd0c36cfc 100644
--- a/spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'WikiPages::DestroyService#execute' do |container_type|
- let(:container) { create(container_type) }
+ let(:container) { create(container_type) } # rubocop:disable Rails/SaveBang
let(:user) { create(:user) }
let(:page) { create(:wiki_page) }
@@ -32,9 +32,19 @@ RSpec.shared_examples 'WikiPages::DestroyService#execute' do |container_type|
)
end
- it 'does not increment the delete count if the deletion failed' do
- counter = Gitlab::UsageDataCounters::WikiPageCounter
+ context 'when the deletion fails' do
+ before do
+ expect(page).to receive(:delete).and_return(false)
+ end
+
+ it 'returns an error response' do
+ response = service.execute(page)
+ expect(response).to be_error
+ end
- expect { service.execute(nil) }.not_to change { counter.read(:delete) }
+ it 'does not increment the delete count if the deletion failed' do
+ counter = Gitlab::UsageDataCounters::WikiPageCounter
+ expect { service.execute(page) }.not_to change { counter.read(:delete) }
+ end
end
end
diff --git a/spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb b/spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb
index 0191a6dfbc9..fd10dd4367e 100644
--- a/spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb
@@ -19,8 +19,10 @@ RSpec.shared_examples 'WikiPages::UpdateService#execute' do |container_type|
subject(:service) { described_class.new(container: container, current_user: user, params: opts) }
it 'updates the wiki page' do
- updated_page = service.execute(page)
+ response = service.execute(page)
+ updated_page = response.payload[:page]
+ expect(response).to be_success
expect(updated_page).to be_valid
expect(updated_page.message).to eq(opts[:message])
expect(updated_page.content).to eq(opts[:content])
@@ -81,7 +83,11 @@ RSpec.shared_examples 'WikiPages::UpdateService#execute' do |container_type|
end
it 'reports the error' do
- expect(service.execute(page)).to be_invalid
+ response = service.execute(page)
+ page = response.payload[:page]
+
+ expect(response).to be_error
+ expect(page).to be_invalid
.and have_attributes(errors: be_present)
end
end
diff --git a/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb b/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb
index f143cbc7165..5a9a3dfc2d2 100644
--- a/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb
+++ b/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb
@@ -63,7 +63,7 @@ RSpec.shared_examples 'uploads migration worker' do
if success > 0
it 'outputs the reports' do
- expect(Rails.logger).to receive(:info).with(%r{Migrated #{success}/#{total} files})
+ expect(Gitlab::AppLogger).to receive(:info).with(%r{Migrated #{success}/#{total} files})
perform(uploads)
end
@@ -71,7 +71,7 @@ RSpec.shared_examples 'uploads migration worker' do
if failures > 0
it 'outputs upload failures' do
- expect(Rails.logger).to receive(:warn).with(/Error .* I am a teapot/)
+ expect(Gitlab::AppLogger).to receive(:warn).with(/Error .* I am a teapot/)
perform(uploads)
end
diff --git a/spec/support/snowplow.rb b/spec/support/snowplow.rb
new file mode 100644
index 00000000000..58812b8f4e6
--- /dev/null
+++ b/spec/support/snowplow.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+RSpec.configure do |config|
+ config.before(:each, :snowplow) do
+ # Using a high buffer size to not cause early flushes
+ buffer_size = 100
+ # WebMock is set up to allow requests to `localhost`
+ host = 'localhost'
+
+ allow(Gitlab::Tracking)
+ .to receive(:emitter)
+ .and_return(SnowplowTracker::Emitter.new(host, buffer_size: buffer_size))
+
+ stub_application_setting(snowplow_enabled: true)
+
+ allow(Gitlab::Tracking).to receive(:event).and_call_original
+ end
+
+ config.after(:each, :snowplow) do
+ Gitlab::Tracking.send(:snowplow).flush
+ end
+end
diff --git a/spec/support/test_reports/test_reports_helper.rb b/spec/support/test_reports/test_reports_helper.rb
index 6ba50c83b25..ad9ecb6f460 100644
--- a/spec/support/test_reports/test_reports_helper.rb
+++ b/spec/support/test_reports/test_reports_helper.rb
@@ -10,12 +10,12 @@ module TestReportsHelper
status: Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS)
end
- def create_test_case_rspec_failed(name = 'test_spec')
+ def create_test_case_rspec_failed(name = 'test_spec', execution_time = 2.22)
Gitlab::Ci::Reports::TestCase.new(
name: 'Test#sum when a is 1 and b is 3 returns summary',
classname: "spec.#{name}",
file: './spec/test_spec.rb',
- execution_time: 2.22,
+ execution_time: execution_time,
system_output: sample_rspec_failed_message,
status: Gitlab::Ci::Reports::TestCase::STATUS_FAILED)
end
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index 6ac46712aa3..a2cc2b12e5e 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -160,7 +160,8 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
expect(raw_repo.empty?).to be(false)
end
end
- end # backup_restore task
+ end
+ # backup_restore task
describe 'backup' do
before do
@@ -391,7 +392,8 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
end
end
- end # backup_create task
+ end
+ # backup_create task
describe "Skipping items" do
before do
@@ -486,4 +488,5 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
expect(backup_tar).to match(/\d+_\d{4}_\d{2}_\d{2}_\d+\.\d+\.\d+.*_gitlab_backup.tar$/)
end
end
-end # gitlab:app namespace
+end
+# gitlab:app namespace
diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb
index a78506803be..99efd394e83 100644
--- a/spec/tasks/gitlab/db_rake_spec.rb
+++ b/spec/tasks/gitlab/db_rake_spec.rb
@@ -164,9 +164,31 @@ RSpec.describe 'gitlab:db namespace rake task' do
end
end
- def run_rake_task(task_name)
+ describe 'reindex' do
+ context 'when no index_name is given' do
+ it 'raises an error' do
+ expect do
+ run_rake_task('gitlab:db:reindex', '')
+ end.to raise_error(ArgumentError, /must give the index name/)
+ end
+ end
+
+ it 'calls the index rebuilder with the proper arguments' do
+ reindex = double('rebuilder')
+
+ expect(Gitlab::Database::ConcurrentReindex).to receive(:new)
+ .with('some_index_name', logger: instance_of(Logger))
+ .and_return(reindex)
+
+ expect(reindex).to receive(:execute)
+
+ run_rake_task('gitlab:db:reindex', '[some_index_name]')
+ end
+ end
+
+ def run_rake_task(task_name, arguments = '')
Rake::Task[task_name].reenable
- Rake.application.invoke_task task_name
+ Rake.application.invoke_task("#{task_name}#{arguments}")
end
def expect_multiple_executions_of_task(test_task_name, task_to_invoke, count: 2)
diff --git a/spec/tasks/gitlab/usage_data_rake_spec.rb b/spec/tasks/gitlab/usage_data_rake_spec.rb
new file mode 100644
index 00000000000..0ee6fbef53f
--- /dev/null
+++ b/spec/tasks/gitlab/usage_data_rake_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+
+RSpec.describe 'gitlab:usage data take tasks' do
+ before do
+ Rake.application.rake_require 'tasks/gitlab/usage_data'
+ # stub prometheus external http calls https://gitlab.com/gitlab-org/gitlab/-/issues/245277
+ stub_request(:get, %r{^http[s]?://::1:9090/-/ready})
+ .to_return(
+ status: 200,
+ body: [{}].to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, %r{^http[s]?://::1:9090/api/v1/query\?query=.*})
+ .to_return(
+ status: 200,
+ body: [{}].to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ describe 'dump_sql_in_yaml' do
+ it 'dumps SQL queries in yaml format' do
+ expect { run_rake_task('gitlab:usage_data:dump_sql_in_yaml') }.to output(/.*recorded_at:.*/).to_stdout
+ end
+ end
+
+ describe 'dump_sql_in_json' do
+ it 'dumps SQL queries in json format' do
+ expect { run_rake_task('gitlab:usage_data:dump_sql_in_json') }.to output(/.*"recorded_at":.*/).to_stdout
+ end
+ end
+end
diff --git a/spec/uploaders/object_storage_spec.rb b/spec/uploaders/object_storage_spec.rb
index 12c936e154b..c73a9a7aab1 100644
--- a/spec/uploaders/object_storage_spec.rb
+++ b/spec/uploaders/object_storage_spec.rb
@@ -210,6 +210,27 @@ RSpec.describe ObjectStorage do
end
end
+ describe '#use_open_file' do
+ context 'when file is stored locally' do
+ it "returns the file" do
+ expect { |b| uploader.use_open_file(&b) }.to yield_with_args(an_instance_of(ObjectStorage::Concern::OpenFile))
+ end
+ end
+
+ context 'when file is stored remotely' do
+ let(:store) { described_class::Store::REMOTE }
+
+ before do
+ stub_artifacts_object_storage
+ stub_request(:get, %r{s3.amazonaws.com/#{uploader.path}}).to_return(status: 200, body: '')
+ end
+
+ it "returns the file" do
+ expect { |b| uploader.use_open_file(&b) }.to yield_with_args(an_instance_of(ObjectStorage::Concern::OpenFile))
+ end
+ end
+ end
+
describe '#migrate!' do
subject { uploader.migrate!(new_store) }
@@ -414,28 +435,38 @@ RSpec.describe ObjectStorage do
subject { uploader_class.workhorse_authorize(has_length: has_length, maximum_size: maximum_size) }
- shared_examples 'uses local storage' do
+ shared_examples 'returns the maximum size given' do
it "returns temporary path" do
- is_expected.to have_key(:TempPath)
+ expect(subject[:MaximumSize]).to eq(maximum_size)
+ end
+ end
+
+ shared_examples 'uses local storage' do
+ it_behaves_like 'returns the maximum size given' do
+ it "returns temporary path" do
+ is_expected.to have_key(:TempPath)
- expect(subject[:TempPath]).to start_with(uploader_class.root)
- expect(subject[:TempPath]).to include(described_class::TMP_UPLOAD_PATH)
+ expect(subject[:TempPath]).to start_with(uploader_class.root)
+ expect(subject[:TempPath]).to include(described_class::TMP_UPLOAD_PATH)
+ end
end
end
shared_examples 'uses remote storage' do
- it "returns remote store" do
- is_expected.to have_key(:RemoteObject)
+ it_behaves_like 'returns the maximum size given' do
+ it "returns remote store" 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)
+ 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)
+ end
end
end
@@ -834,4 +865,19 @@ RSpec.describe ObjectStorage do
end
end
end
+
+ describe 'OpenFile' do
+ subject { ObjectStorage::Concern::OpenFile.new(file) }
+
+ let(:file) { double(read: true, size: true, path: true) }
+
+ it 'delegates read and size methods' do
+ expect(subject.read).to eq(true)
+ expect(subject.size).to eq(true)
+ end
+
+ it 'does not delegate path method' do
+ expect { subject.path }.to raise_error(NoMethodError)
+ end
+ end
end
diff --git a/spec/uploaders/terraform/versioned_state_uploader_spec.rb b/spec/uploaders/terraform/versioned_state_uploader_spec.rb
new file mode 100644
index 00000000000..ecc3f943480
--- /dev/null
+++ b/spec/uploaders/terraform/versioned_state_uploader_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Terraform::VersionedStateUploader do
+ subject { model.file }
+
+ let(:model) { create(:terraform_state_version, :with_file) }
+
+ before do
+ stub_terraform_state_object_storage
+ end
+
+ describe '#filename' do
+ it 'contains the UUID of the terraform state record' do
+ expect(subject.filename).to eq("#{model.version}.tfstate")
+ end
+ end
+
+ describe '#store_dir' do
+ it 'hashes the project ID and UUID' do
+ expect(Gitlab::HashedPath).to receive(:new)
+ .with(model.uuid, root_hash: model.project_id)
+ .and_return(:store_dir)
+
+ expect(subject.store_dir).to eq(:store_dir)
+ end
+ end
+end
diff --git a/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb b/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb
new file mode 100644
index 00000000000..ef40829c29b
--- /dev/null
+++ b/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'admin/application_settings/_package_registry' do
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:default_plan_limits) { create(:plan_limits, :default_plan, :with_package_file_sizes) }
+ let_it_be(:application_setting) { build(:application_setting) }
+ let(:page) { Capybara::Node::Simple.new(rendered) }
+
+ before do
+ assign(:application_setting, application_setting)
+ allow(view).to receive(:current_user) { admin }
+ allow(view).to receive(:expanded) { true }
+ end
+
+ subject { render partial: 'admin/application_settings/package_registry' }
+
+ context 'package file size limits' do
+ before do
+ assign(:plans, [default_plan_limits.plan])
+ end
+
+ it 'has fields for max package file sizes' do
+ subject
+
+ expect(rendered).to have_field('Maximum Conan package file size in bytes', type: 'number')
+ expect(page.find_field('Maximum Conan package file size in bytes').value).to eq(default_plan_limits.conan_max_file_size.to_s)
+
+ expect(rendered).to have_field('Maximum Maven package file size in bytes', type: 'number')
+ expect(page.find_field('Maximum Maven package file size in bytes').value).to eq(default_plan_limits.maven_max_file_size.to_s)
+
+ expect(rendered).to have_field('Maximum NPM package file size in bytes', type: 'number')
+ expect(page.find_field('Maximum NPM package file size in bytes').value).to eq(default_plan_limits.npm_max_file_size.to_s)
+
+ expect(rendered).to have_field('Maximum NuGet package file size in bytes', type: 'number')
+ expect(page.find_field('Maximum NuGet package file size in bytes').value).to eq(default_plan_limits.nuget_max_file_size.to_s)
+
+ expect(rendered).to have_field('Maximum PyPI package file size in bytes', type: 'number')
+ expect(page.find_field('Maximum PyPI package file size in bytes').value).to eq(default_plan_limits.pypi_max_file_size.to_s)
+ end
+
+ it 'does not display the plan name when there is only one plan' do
+ subject
+
+ expect(page).not_to have_content('Default')
+ end
+ end
+
+ context 'with multiple plans' do
+ let_it_be(:plan) { create(:plan, name: 'Gold') }
+ let_it_be(:gold_plan_limits) { create(:plan_limits, :with_package_file_sizes, plan: plan) }
+
+ before do
+ assign(:plans, [default_plan_limits.plan, gold_plan_limits.plan])
+ end
+
+ it 'displays the plan name when there is more than one plan' do
+ subject
+
+ expect(page).to have_content('Default')
+ expect(page).to have_content('Gold')
+ end
+ end
+end
diff --git a/spec/views/admin/services/index.html.haml_spec.rb b/spec/views/admin/services/index.html.haml_spec.rb
new file mode 100644
index 00000000000..e8cd2dde67e
--- /dev/null
+++ b/spec/views/admin/services/index.html.haml_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'admin/services/index.html.haml' do
+ before do
+ assign(:services, build_stubbed_list(:service, 1))
+ assign(:existing_instance_types, [])
+ end
+
+ context 'user has not dismissed Service Templates deprecation message' do
+ it 'shows the message' do
+ allow(view).to receive(:show_service_templates_deprecated?).and_return(true)
+
+ render
+
+ expect(rendered).to have_content('Service Templates will soon be deprecated.')
+ end
+ end
+
+ context 'user has dismissed Service Templates deprecation message' do
+ it 'does not show the message' do
+ allow(view).to receive(:show_service_templates_deprecated?).and_return(false)
+
+ render
+
+ expect(rendered).not_to have_content('Service Templates will soon be deprecated.')
+ end
+ 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 9c5ff9925c1..c7e0edbcd58 100644
--- a/spec/views/admin/sessions/two_factor.html.haml_spec.rb
+++ b/spec/views/admin/sessions/two_factor.html.haml_spec.rb
@@ -32,6 +32,10 @@ RSpec.describe 'admin/sessions/two_factor.html.haml' do
context 'user has u2f active' do
let(:user) { create(:admin, :two_factor_via_u2f) }
+ before do
+ stub_feature_flags(webauthn: false)
+ end
+
it 'shows enter u2f form' do
render
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 d1e756422d5..777dc0c8571 100644
--- a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
@@ -66,6 +66,14 @@ RSpec.describe 'layouts/nav/sidebar/_admin' do
it_behaves_like 'page has active tab', 'Messages'
end
+ context 'on analytics' do
+ before do
+ allow(controller).to receive(:controller_name).and_return('dev_ops_report')
+ end
+
+ it_behaves_like 'page has active tab', 'Analytics'
+ end
+
context 'on hooks' do
before do
allow(controller).to receive(:controller_name).and_return('hooks')
diff --git a/spec/views/layouts/nav/sidebar/_instance_statistics.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_instance_statistics.html.haml_spec.rb
deleted file mode 100644
index d3b57f6dfcf..00000000000
--- a/spec/views/layouts/nav/sidebar/_instance_statistics.html.haml_spec.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'layouts/nav/sidebar/_instance_statistics' do
- it_behaves_like 'has nav sidebar'
-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
new file mode 100644
index 00000000000..c3cb0c83f35
--- /dev/null
+++ b/spec/views/notify/autodevops_disabled_email.text.erb_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'notify/autodevops_disabled_email.text.erb' do
+ include Devise::Test::ControllerHelpers
+
+ let(:user) { create(:user, developer_projects: [project]) }
+ let(:project) { create(:project, :repository) }
+
+ let(:pipeline) do
+ create(:ci_pipeline,
+ :failed,
+ project: project,
+ user: user,
+ ref: project.default_branch,
+ sha: project.commit.sha)
+ end
+
+ before do
+ assign(:project, project)
+ assign(:pipeline, pipeline)
+ end
+
+ context 'when the pipeline contains a failed job' do
+ let!(:build) { create(:ci_build, :failed, :trace_live, pipeline: pipeline, project: pipeline.project) }
+
+ it 'renders the email correctly' do
+ render
+
+ expect(rendered).to have_content("Auto DevOps pipeline was disabled for #{project.name}")
+ expect(rendered).to match(/Pipeline ##{pipeline.id} .* triggered by #{pipeline.user.name}/)
+ expect(rendered).to have_content("Stage: #{build.stage}")
+ expect(rendered).to have_content("Name: #{build.name}")
+ expect(rendered).not_to have_content("Trace:")
+ end
+ end
+end
diff --git a/spec/views/projects/ci/lints/show.html.haml_spec.rb b/spec/views/projects/ci/lints/show.html.haml_spec.rb
index a71cea6d3c8..f59ad3f5f84 100644
--- a/spec/views/projects/ci/lints/show.html.haml_spec.rb
+++ b/spec/views/projects/ci/lints/show.html.haml_spec.rb
@@ -4,16 +4,16 @@ require 'spec_helper'
RSpec.describe 'projects/ci/lints/show' do
include Devise::Test::ControllerHelpers
- let(:project) { create(:project, :repository) }
- let(:config_processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(content)) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let(:lint) { Gitlab::Ci::Lint.new(project: project, current_user: user) }
+ let(:result) { lint.validate(YAML.dump(content)) }
describe 'XSS protection' do
before do
assign(:project, project)
- assign(:status, true)
- assign(:builds, config_processor.builds)
- assign(:stages, config_processor.stages)
- assign(:jobs, config_processor.jobs)
+ assign(:result, result)
+ stub_feature_flags(ci_lint_vue: false)
end
context 'when builds attrbiutes contain HTML nodes' do
@@ -66,10 +66,8 @@ RSpec.describe 'projects/ci/lints/show' do
before do
assign(:project, project)
- assign(:status, true)
- assign(:builds, config_processor.builds)
- assign(:stages, config_processor.stages)
- assign(:jobs, config_processor.jobs)
+ assign(:result, result)
+ stub_feature_flags(ci_lint_vue: false)
end
it 'shows the correct values' do
@@ -85,13 +83,13 @@ RSpec.describe 'projects/ci/lints/show' do
context 'when content has warnings' do
before do
- assign(:warnings, ['Warning 1', 'Warning 2'])
+ allow(result).to receive(:warnings).and_return(['Warning 1', 'Warning 2'])
end
it 'shows warning messages' do
render
- expect(rendered).to have_content('Warning:')
+ expect(rendered).to have_content('2 warning(s) found:')
expect(rendered).to have_content('Warning 1')
expect(rendered).to have_content('Warning 2')
end
@@ -99,11 +97,15 @@ RSpec.describe 'projects/ci/lints/show' do
end
context 'when the content is invalid' do
+ let(:content) { double(:content) }
+
before do
+ allow(result).to receive(:warnings).and_return(['Warning 1', 'Warning 2'])
+ allow(result).to receive(:errors).and_return(['Undefined error'])
+
assign(:project, project)
- assign(:status, false)
- assign(:errors, ['Undefined error'])
- assign(:warnings, ['Warning 1', 'Warning 2'])
+ assign(:result, result)
+ stub_feature_flags(ci_lint_vue: false)
end
it 'shows error message' do
@@ -117,7 +119,7 @@ RSpec.describe 'projects/ci/lints/show' do
it 'shows warning messages' do
render
- expect(rendered).to have_content('Warning:')
+ expect(rendered).to have_content('2 warning(s) found:')
expect(rendered).to have_content('Warning 1')
expect(rendered).to have_content('Warning 2')
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 55a74dc8229..215d404e395 100644
--- a/spec/views/projects/merge_requests/edit.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/edit.html.haml_spec.rb
@@ -20,6 +20,7 @@ RSpec.describe 'projects/merge_requests/edit.html.haml' do
target_project: project,
author: user,
assignees: [user],
+ reviewers: [user],
milestone: milestone)
end
diff --git a/spec/views/projects/pipelines/new.html.haml_spec.rb b/spec/views/projects/pipelines/new.html.haml_spec.rb
index 2deacfa8478..9c5e46b6a17 100644
--- a/spec/views/projects/pipelines/new.html.haml_spec.rb
+++ b/spec/views/projects/pipelines/new.html.haml_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'projects/pipelines/new' do
it 'displays the warnings' do
render
- expect(rendered).to have_css('div.alert-warning')
+ expect(rendered).to have_css('div.bs-callout-warning')
expect(rendered).to have_content('warning 1')
expect(rendered).to have_content('warning 2')
end
diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb
index 49add434ab5..b998023b40e 100644
--- a/spec/views/projects/pipelines/show.html.haml_spec.rb
+++ b/spec/views/projects/pipelines/show.html.haml_spec.rb
@@ -16,24 +16,6 @@ RSpec.describe 'projects/pipelines/show' do
stub_feature_flags(new_pipeline_form: false)
end
- shared_examples 'pipeline with warning messages' do
- let(:warning_messages) do
- [double(content: 'warning 1'), double(content: 'warning 2')]
- end
-
- before do
- allow(pipeline).to receive(:warning_messages).and_return(warning_messages)
- end
-
- it 'displays the warnings' do
- render
-
- expect(rendered).to have_css('.bs-callout-warning')
- expect(rendered).to have_content('warning 1')
- expect(rendered).to have_content('warning 2')
- end
- end
-
context 'when pipeline has errors' do
before do
allow(pipeline).to receive(:yaml_errors).and_return('some errors')
@@ -51,10 +33,6 @@ RSpec.describe 'projects/pipelines/show' do
expect(rendered).not_to have_css('ul.pipelines-tabs')
end
-
- context 'when pipeline has also warnings' do
- it_behaves_like 'pipeline with warning messages'
- end
end
context 'when pipeline is valid' do
@@ -69,9 +47,5 @@ RSpec.describe 'projects/pipelines/show' do
expect(rendered).to have_css('ul.pipelines-tabs')
end
-
- context 'when pipeline has warnings' do
- it_behaves_like 'pipeline with warning messages'
- end
end
end
diff --git a/spec/views/registrations/welcome.html.haml_spec.rb b/spec/views/registrations/welcome.html.haml_spec.rb
new file mode 100644
index 00000000000..56a7784a134
--- /dev/null
+++ b/spec/views/registrations/welcome.html.haml_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'registrations/welcome' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { User.new }
+
+ before do
+ allow(view).to receive(:current_user).and_return(user)
+ allow(view).to receive(:in_subscription_flow?).and_return(false)
+ allow(view).to receive(:in_trial_flow?).and_return(false)
+ allow(view).to receive(:in_invitation_flow?).and_return(false)
+ allow(view).to receive(:in_oauth_flow?).and_return(false)
+ allow(view).to receive(:experiment_enabled?).with(:onboarding_issues).and_return(false)
+ allow(Gitlab).to receive(:com?).and_return(false)
+
+ render
+ end
+
+ subject { rendered }
+
+ it { is_expected.not_to have_selector('label[for="user_setup_for_company"]') }
+ it { is_expected.to have_button('Get started!') }
+end
diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb
index cd7a3559538..9e95dc40ff8 100644
--- a/spec/views/search/_results.html.haml_spec.rb
+++ b/spec/views/search/_results.html.haml_spec.rb
@@ -3,13 +3,16 @@
require 'spec_helper'
RSpec.describe 'search/_results' do
+ let(:search_objects) { Issue.page(1).per(2) }
+ let(:scope) { 'issues' }
+
before do
controller.params[:action] = 'show'
create_list(:issue, 3)
- @search_objects = Issue.page(1).per(2)
- @scope = 'issues'
+ @search_objects = search_objects
+ @scope = scope
@search_term = 'foo'
end
@@ -30,4 +33,34 @@ RSpec.describe 'search/_results' do
expect(rendered).not_to have_content(/Showing .* of .*/)
end
end
+
+ context 'rendering all types of search results' do
+ let_it_be(:project) { create(:project, :repository, :wiki_repo) }
+ let_it_be(:issue) { create(:issue, project: project, title: '*') }
+ let_it_be(:merge_request) { create(:merge_request, title: '*', source_project: project, target_project: project) }
+ let_it_be(:milestone) { create(:milestone, title: '*', project: project) }
+ let_it_be(:note) { create(:discussion_note_on_issue, project: project, note: '*') }
+ let_it_be(:wiki_blob) { create(:wiki_page, project: project, content: '*') }
+ let_it_be(:user) { create(:admin) }
+
+ %w[issues blobs notes wiki_blobs merge_requests milestones].each do |search_scope|
+ context "when scope is #{search_scope}" do
+ let(:scope) { search_scope }
+ let(:search_objects) { Gitlab::ProjectSearchResults.new(user, '*', project: project).objects(scope) }
+
+ it 'renders the click text event tracking attributes' do
+ render
+
+ expect(rendered).to have_selector('[data-track-event=click_text]')
+ expect(rendered).to have_selector('[data-track-property=search_result]')
+ end
+
+ it 'renders the state filter drop down' do
+ render
+
+ expect(rendered).to have_selector('#js-search-filter-by-state')
+ end
+ end
+ end
+ end
end
diff --git a/spec/views/shared/deploy_tokens/_form.html.haml_spec.rb b/spec/views/shared/deploy_tokens/_form.html.haml_spec.rb
new file mode 100644
index 00000000000..3508ba8cca9
--- /dev/null
+++ b/spec/views/shared/deploy_tokens/_form.html.haml_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'shared/deploy_tokens/_form.html.haml' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:token) { build(:deploy_token) }
+
+ RSpec.shared_examples "display deploy token settings" do |role, shows_package_registry_permissions|
+ before do
+ subject.add_user(user, role)
+ allow(view).to receive(:current_user).and_return(user)
+ stub_config(packages: { enabled: packages_enabled })
+ end
+
+ it "correctly renders the form" do
+ render 'shared/deploy_tokens/form', token: token, group_or_project: subject
+
+ if shows_package_registry_permissions
+ expect(rendered).to have_content('Allows read access to the package registry')
+ else
+ expect(rendered).not_to have_content('Allows read access to the package registry')
+ end
+ end
+ end
+
+ context "when the subject is a project" do
+ let_it_be(:subject, refind: true) { create(:project, :private) }
+
+ where(:packages_enabled, :feature_enabled, :role, :shows_package_registry_permissions) do
+ true | true | :maintainer | true
+ false | true | :maintainer | false
+ true | false | :maintainer | false
+ false | false | :maintainer | false
+ end
+
+ with_them do
+ before do
+ subject.update!(packages_enabled: feature_enabled)
+ end
+
+ it_behaves_like 'display deploy token settings', params[:role], params[:shows_package_registry_permissions]
+ end
+ end
+
+ context "when the subject is a group" do
+ let_it_be(:subject, refind: true) { create(:group, :private) }
+
+ where(:packages_enabled, :role, :shows_package_registry_permissions) do
+ true | :owner | true
+ false | :owner | false
+ true | :maintainer | true
+ false | :maintainer | false
+ end
+
+ with_them do
+ it_behaves_like 'display deploy token settings', params[:role], params[:shows_package_registry_permissions]
+ end
+ end
+end
diff --git a/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb b/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb
new file mode 100644
index 00000000000..620900b3402
--- /dev/null
+++ b/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::InstanceStatistics::CountJobTriggerWorker do
+ it_behaves_like 'an idempotent worker'
+
+ context 'triggers a job for each measurement identifiers' do
+ let(:expected_count) { Analytics::InstanceStatistics::Measurement.identifiers.size }
+
+ it 'triggers CounterJobWorker jobs' do
+ subject.perform
+
+ expect(Analytics::InstanceStatistics::CounterJobWorker.jobs.count).to eq(expected_count)
+ end
+ end
+
+ context 'when the `store_instance_statistics_measurements` feature flag is off' do
+ before do
+ stub_feature_flags(store_instance_statistics_measurements: false)
+ end
+
+ it 'does not trigger any CounterJobWorker job' do
+ subject.perform
+
+ expect(Analytics::InstanceStatistics::CounterJobWorker.jobs.count).to eq(0)
+ end
+ end
+end
diff --git a/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb b/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb
new file mode 100644
index 00000000000..8db86071dc4
--- /dev/null
+++ b/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do
+ let_it_be(:user_1) { create(:user) }
+ let_it_be(:user_2) { create(:user) }
+
+ let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:users) }
+ let(:recorded_at) { Time.zone.now }
+ let(:job_args) { [users_measurement_identifier, user_1.id, user_2.id, recorded_at] }
+
+ before do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
+ end
+
+ include_examples 'an idempotent worker' do
+ it 'counts a scope and stores the result' do
+ subject
+
+ measurement = Analytics::InstanceStatistics::Measurement.first
+ expect(measurement.recorded_at).to be_like_time(recorded_at)
+ expect(measurement.identifier).to eq('users')
+ expect(measurement.count).to eq(2)
+ end
+ end
+
+ context 'when no records are in the database' do
+ let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:groups) }
+
+ subject { described_class.new.perform(users_measurement_identifier, nil, nil, recorded_at) }
+
+ it 'sets 0 as the count' do
+ subject
+
+ measurement = Analytics::InstanceStatistics::Measurement.first
+ expect(measurement.recorded_at).to be_like_time(recorded_at)
+ expect(measurement.identifier).to eq('groups')
+ expect(measurement.count).to eq(0)
+ end
+ end
+
+ it 'does not raise error when inserting duplicated measurement' do
+ subject
+
+ expect { subject }.not_to raise_error
+ end
+
+ it 'does not insert anything when BatchCount returns error' do
+ allow(Gitlab::Database::BatchCount).to receive(:batch_count).and_return(Gitlab::Database::BatchCounter::FALLBACK)
+
+ expect { subject }.not_to change { Analytics::InstanceStatistics::Measurement.count }
+ end
+end
diff --git a/spec/workers/ci/build_trace_chunk_flush_worker_spec.rb b/spec/workers/ci/build_trace_chunk_flush_worker_spec.rb
new file mode 100644
index 00000000000..352ad6d4cf6
--- /dev/null
+++ b/spec/workers/ci/build_trace_chunk_flush_worker_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::BuildTraceChunkFlushWorker do
+ let(:data) { 'x' * Ci::BuildTraceChunk::CHUNK_SIZE }
+
+ let(:chunk) do
+ create(:ci_build_trace_chunk, :redis_with_data, initial_data: data)
+ end
+
+ it 'migrates chunk to a permanent store' do
+ expect(chunk).to be_live
+
+ described_class.new.perform(chunk.id)
+
+ expect(chunk.reload).to be_persisted
+ end
+
+ describe '#perform' do
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [chunk.id] }
+
+ it 'migrates build trace chunk to a safe store' do
+ subject
+
+ expect(chunk.reload).to be_persisted
+ end
+ end
+ end
+end
diff --git a/spec/workers/ci/create_cross_project_pipeline_worker_spec.rb b/spec/workers/ci/create_cross_project_pipeline_worker_spec.rb
index 95dcf5624cc..116e6878281 100644
--- a/spec/workers/ci/create_cross_project_pipeline_worker_spec.rb
+++ b/spec/workers/ci/create_cross_project_pipeline_worker_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Ci::CreateCrossProjectPipelineWorker do
describe '#perform' do
context 'when bridge exists' do
it 'calls cross project pipeline creation service' do
- expect(Ci::CreateCrossProjectPipelineService)
+ expect(Ci::CreateDownstreamPipelineService)
.to receive(:new)
.with(project, user)
.and_return(service)
@@ -26,7 +26,7 @@ RSpec.describe Ci::CreateCrossProjectPipelineWorker do
context 'when bridge does not exist' do
it 'does nothing' do
- expect(Ci::CreateCrossProjectPipelineService)
+ expect(Ci::CreateDownstreamPipelineService)
.not_to receive(:new)
described_class.new.perform(non_existing_record_id)
diff --git a/spec/workers/ci/pipelines/create_artifact_worker_spec.rb b/spec/workers/ci/pipelines/create_artifact_worker_spec.rb
new file mode 100644
index 00000000000..31d2c4e9559
--- /dev/null
+++ b/spec/workers/ci/pipelines/create_artifact_worker_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ci::Pipelines::CreateArtifactWorker do
+ describe '#perform' do
+ subject { described_class.new.perform(pipeline_id) }
+
+ context 'when pipeline exists' do
+ let(:pipeline) { create(:ci_pipeline) }
+ let(:pipeline_id) { pipeline.id }
+
+ it 'calls pipeline report result service' do
+ expect_next_instance_of(::Ci::Pipelines::CreateArtifactService) do |create_artifact_service|
+ expect(create_artifact_service).to receive(:execute)
+ end
+
+ subject
+ end
+ end
+
+ context 'when pipeline does not exist' do
+ let(:pipeline_id) { non_existing_record_id }
+
+ it 'does not call pipeline create artifact service' do
+ expect(Ci::Pipelines::CreateArtifactService).not_to receive(:execute)
+
+ subject
+ end
+ end
+ end
+end
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 d9f2dd326dd..f510852e753 100644
--- a/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb
+++ b/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb
@@ -29,10 +29,8 @@ RSpec.describe Ci::RefDeleteUnlockArtifactsWorker do
context 'when user exists' do
let(:user_id) { project.creator.id }
- context 'when ci ref exists' do
- before do
- create(:ci_ref, ref_path: ref)
- end
+ context 'when ci ref exists for project' do
+ let!(:ci_ref) { create(:ci_ref, ref_path: ref, project: project) }
it 'calls the service' do
service = spy(Ci::UnlockArtifactsService)
@@ -40,17 +38,33 @@ RSpec.describe Ci::RefDeleteUnlockArtifactsWorker do
perform
- expect(service).to have_received(:execute)
+ expect(service).to have_received(:execute).with(ci_ref)
end
end
- context 'when ci ref does not exist' do
+ context 'when ci ref does not exist for the given project' do
+ let!(:another_ci_ref) { create(:ci_ref, ref_path: ref) }
+
it 'does not call the service' do
expect(Ci::UnlockArtifactsService).not_to receive(:new)
perform
end
end
+
+ context 'when same ref path exists for a different project' do
+ let!(:another_ci_ref) { create(:ci_ref, ref_path: ref) }
+ let!(:ci_ref) { create(:ci_ref, ref_path: ref, project: project) }
+
+ it 'calls the service with the correct ref_id' do
+ service = spy(Ci::UnlockArtifactsService)
+ expect(Ci::UnlockArtifactsService).to receive(:new).and_return(service)
+
+ perform
+
+ expect(service).to have_received(:execute).with(ci_ref)
+ end
+ end
end
context 'when user does not exist' 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
new file mode 100644
index 00000000000..0bb6822a0a5
--- /dev/null
+++ b/spec/workers/ci_platform_metrics_update_cron_worker_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe CiPlatformMetricsUpdateCronWorker, type: :worker do
+ describe '#perform' do
+ subject { described_class.new.perform }
+
+ it 'inserts new platform metrics' do
+ expect(CiPlatformMetric).to receive(:insert_auto_devops_platform_targets!).and_call_original
+
+ subject
+ end
+ end
+end
diff --git a/spec/workers/cluster_update_app_worker_spec.rb b/spec/workers/cluster_update_app_worker_spec.rb
index c24f40024fd..8b8c1c82099 100644
--- a/spec/workers/cluster_update_app_worker_spec.rb
+++ b/spec/workers/cluster_update_app_worker_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe ClusterUpdateAppWorker do
subject { described_class.new }
around do |example|
- Timecop.freeze(Time.current) { example.run }
+ freeze_time { example.run }
end
before do
diff --git a/spec/workers/create_pipeline_worker_spec.rb b/spec/workers/create_pipeline_worker_spec.rb
index 6a3729fa28a..da85d700429 100644
--- a/spec/workers/create_pipeline_worker_spec.rb
+++ b/spec/workers/create_pipeline_worker_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe CreatePipelineWorker do
context 'when a project not found' do
it 'does not call the Service' do
expect(Ci::CreatePipelineService).not_to receive(:new)
- expect { worker.perform(99, create(:user).id, 'master', :web) }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { worker.perform(non_existing_record_id, create(:user).id, 'master', :web) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
@@ -18,7 +18,7 @@ RSpec.describe CreatePipelineWorker do
it 'does not call the Service' do
expect(Ci::CreatePipelineService).not_to receive(:new)
- expect { worker.perform(project.id, 99, project.default_branch, :web) }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { worker.perform(project.id, non_existing_record_id, project.default_branch, :web) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
diff --git a/spec/workers/delete_diff_files_worker_spec.rb b/spec/workers/delete_diff_files_worker_spec.rb
index b3b01588e0b..cf26dbabb97 100644
--- a/spec/workers/delete_diff_files_worker_spec.rb
+++ b/spec/workers/delete_diff_files_worker_spec.rb
@@ -19,6 +19,12 @@ RSpec.describe DeleteDiffFilesWorker do
.from('collected').to('without_files')
end
+ it 'resets the files_count of the diff' do
+ expect { described_class.new.perform(merge_request_diff.id) }
+ .to change { merge_request_diff.reload.files_count }
+ .from(20).to(0)
+ end
+
it 'does nothing if diff was already marked as "without_files"' do
merge_request_diff.clean!
diff --git a/spec/workers/deployments/finished_worker_spec.rb b/spec/workers/deployments/finished_worker_spec.rb
index e1ec2d89e0a..d0a26ae1547 100644
--- a/spec/workers/deployments/finished_worker_spec.rb
+++ b/spec/workers/deployments/finished_worker_spec.rb
@@ -61,17 +61,5 @@ RSpec.describe Deployments::FinishedWorker do
worker.perform(deployment.id)
end
-
- it 'does not execute webhooks if feature flag is disabled' do
- stub_feature_flags(deployment_webhooks: false)
-
- deployment = create(:deployment)
- project = deployment.project
- create(:project_hook, deployment_events: true, project: project)
-
- expect(WebHookService).not_to receive(:new)
-
- worker.perform(deployment.id)
- end
end
end
diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb
index 223f5aea813..1be6e86b650 100644
--- a/spec/workers/git_garbage_collect_worker_spec.rb
+++ b/spec/workers/git_garbage_collect_worker_spec.rb
@@ -25,12 +25,18 @@ RSpec.describe GitGarbageCollectWorker do
end
shared_examples 'it updates the project statistics' do
- specify do
- expect_any_instance_of(Projects::UpdateStatisticsService).to receive(:execute).and_call_original
- expect(Projects::UpdateStatisticsService)
- .to receive(:new)
- .with(project, nil, statistics: [:repository_size, :lfs_objects_size])
- .and_call_original
+ it 'updates the project statistics' do
+ expect_next_instance_of(Projects::UpdateStatisticsService, project, nil, statistics: [:repository_size, :lfs_objects_size]) do |service|
+ expect(service).to receive(:execute).and_call_original
+ end
+
+ subject.perform(*params)
+ end
+
+ it 'does nothing if the database is read-only' do
+ allow(Gitlab::Database).to receive(:read_only?) { true }
+
+ expect_any_instance_of(Projects::UpdateStatisticsService).not_to receive(:execute)
subject.perform(*params)
end
@@ -141,7 +147,8 @@ RSpec.describe GitGarbageCollectWorker do
end
it 'does nothing if the database is read-only' do
- expect(Gitlab::Database).to receive(:read_only?) { true }
+ allow(Gitlab::Database).to receive(:read_only?) { true }
+
expect_any_instance_of(Gitlab::Cleanup::OrphanLfsFileReferences).not_to receive(:run!)
subject.perform(*params)
diff --git a/spec/workers/issue_placement_worker_spec.rb b/spec/workers/issue_placement_worker_spec.rb
new file mode 100644
index 00000000000..5d4d41b90d0
--- /dev/null
+++ b/spec/workers/issue_placement_worker_spec.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssuePlacementWorker do
+ describe '#perform' do
+ let_it_be(:time) { Time.now.utc }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:common_attrs) { { author: author, project: project } }
+ let_it_be(:unplaced) { common_attrs.merge(relative_position: nil) }
+ let_it_be_with_reload(:issue) { create(:issue, **unplaced, created_at: time) }
+ let_it_be_with_reload(:issue_a) { create(:issue, **unplaced, created_at: time - 1.minute) }
+ let_it_be_with_reload(:issue_b) { create(:issue, **unplaced, created_at: time - 2.minutes) }
+ let_it_be_with_reload(:issue_c) { create(:issue, **unplaced, created_at: time + 1.minute) }
+ let_it_be_with_reload(:issue_d) { create(:issue, **unplaced, created_at: time + 2.minutes) }
+ let_it_be_with_reload(:issue_e) { create(:issue, **common_attrs, relative_position: 10, created_at: time + 1.minute) }
+ let_it_be_with_reload(:issue_f) { create(:issue, **unplaced, created_at: time + 1.minute) }
+
+ let_it_be(:irrelevant) { create(:issue, relative_position: nil, created_at: time) }
+
+ shared_examples 'running the issue placement worker' do
+ let(:issue_id) { issue.id }
+ let(:project_id) { project.id }
+
+ it 'places all issues created at most 5 minutes before this one at the end, most recent last' do
+ expect { run_worker }.not_to change { irrelevant.reset.relative_position }
+
+ expect(project.issues.order_relative_position_asc)
+ .to eq([issue_e, issue_b, issue_a, issue, issue_c, issue_f, issue_d])
+ expect(project.issues.where(relative_position: nil)).not_to exist
+ end
+
+ it 'schedules rebalancing if needed' do
+ issue_a.update!(relative_position: RelativePositioning::MAX_POSITION)
+
+ expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project.id)
+
+ run_worker
+ end
+
+ context 'there are more than QUERY_LIMIT unplaced issues' do
+ before_all do
+ # Ensure there are more than N issues in this set
+ n = described_class::QUERY_LIMIT
+ create_list(:issue, n - 5, **unplaced)
+ end
+
+ it 'limits the sweep to QUERY_LIMIT records, and reschedules placement' do
+ expect(Issue).to receive(:move_nulls_to_end)
+ .with(have_attributes(count: described_class::QUERY_LIMIT))
+ .and_call_original
+
+ expect(described_class).to receive(:perform_async).with(nil, project.id)
+
+ run_worker
+
+ expect(project.issues.where(relative_position: nil)).to exist
+ end
+
+ it 'is eventually correct' do
+ prefix = project.issues.where.not(relative_position: nil).order(:relative_position).to_a
+ moved = project.issues.where.not(id: prefix.map(&:id))
+
+ run_worker
+
+ expect(project.issues.where(relative_position: nil)).to exist
+
+ run_worker
+
+ expect(project.issues.where(relative_position: nil)).not_to exist
+ expect(project.issues.order(:relative_position)).to eq(prefix + moved.order(:created_at, :id))
+ end
+ end
+
+ context 'we are passed bad IDs' do
+ let(:issue_id) { non_existing_record_id }
+ let(:project_id) { non_existing_record_id }
+
+ def max_positions_by_project
+ Issue
+ .group(:project_id)
+ .pluck(:project_id, Issue.arel_table[:relative_position].maximum.as('max_relative_position'))
+ .to_h
+ end
+
+ it 'does move any issues to the end' do
+ expect { run_worker }.not_to change { max_positions_by_project }
+ end
+
+ context 'the project_id refers to an empty project' do
+ let!(:project_id) { create(:project).id }
+
+ it 'does move any issues to the end' do
+ expect { run_worker }.not_to change { max_positions_by_project }
+ end
+ end
+ end
+
+ it 'anticipates the failure to place the issues, and schedules rebalancing' do
+ allow(Issue).to receive(:move_nulls_to_end) { raise RelativePositioning::NoSpaceLeft }
+
+ expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project.id)
+ expect(Gitlab::ErrorTracking)
+ .to receive(:log_exception)
+ .with(RelativePositioning::NoSpaceLeft, worker_arguments)
+
+ run_worker
+ end
+ end
+
+ context 'passing an issue ID' do
+ def run_worker
+ described_class.new.perform(issue_id)
+ end
+
+ let(:worker_arguments) { { issue_id: issue_id, project_id: nil } }
+
+ it_behaves_like 'running the issue placement worker'
+ end
+
+ context 'passing a project ID' do
+ def run_worker
+ described_class.new.perform(nil, project_id)
+ end
+
+ let(:worker_arguments) { { issue_id: nil, project_id: project_id } }
+
+ it_behaves_like 'running the issue placement worker'
+ end
+ end
+end
diff --git a/spec/workers/issue_rebalancing_worker_spec.rb b/spec/workers/issue_rebalancing_worker_spec.rb
new file mode 100644
index 00000000000..8b0fcd4bc5a
--- /dev/null
+++ b/spec/workers/issue_rebalancing_worker_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssueRebalancingWorker do
+ describe '#perform' do
+ let_it_be(:issue) { create(:issue) }
+
+ it 'runs an instance of IssueRebalancingService' do
+ service = double(execute: nil)
+ expect(IssueRebalancingService).to receive(:new).with(issue).and_return(service)
+
+ described_class.new.perform(nil, issue.project_id)
+ end
+
+ it 'anticipates the inability to find the issue' do
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(ActiveRecord::RecordNotFound, include(project_id: -1))
+ expect(IssueRebalancingService).not_to receive(:new)
+
+ described_class.new.perform(nil, -1)
+ end
+
+ it 'anticipates there being too many issues' do
+ service = double
+ allow(service).to receive(:execute) { raise IssueRebalancingService::TooManyIssues }
+ expect(IssueRebalancingService).to receive(:new).with(issue).and_return(service)
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(IssueRebalancingService::TooManyIssues, include(project_id: issue.project_id))
+
+ described_class.new.perform(nil, issue.project_id)
+ end
+
+ it 'takes no action if the value is nil' do
+ expect(IssueRebalancingService).not_to receive(:new)
+ expect(Gitlab::ErrorTracking).not_to receive(:log_exception)
+
+ described_class.new.perform(nil, nil)
+ end
+ end
+end
diff --git a/spec/workers/jira_connect/sync_branch_worker_spec.rb b/spec/workers/jira_connect/sync_branch_worker_spec.rb
new file mode 100644
index 00000000000..2da3ea9d256
--- /dev/null
+++ b/spec/workers/jira_connect/sync_branch_worker_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SyncBranchWorker do
+ describe '#perform' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:project_id) { project.id }
+ let(:branch_name) { 'master' }
+ let(:commit_shas) { %w(b83d6e3 5a62481) }
+
+ subject { described_class.new.perform(project_id, branch_name, commit_shas) }
+
+ def expect_jira_sync_service_execute(args)
+ expect_next_instance_of(JiraConnect::SyncService) do |instance|
+ expect(instance).to receive(:execute).with(args)
+ end
+ end
+
+ it 'calls JiraConnect::SyncService#execute' do
+ expect_jira_sync_service_execute(
+ branches: [instance_of(Gitlab::Git::Branch)],
+ commits: project.commits_by(oids: commit_shas)
+ )
+
+ subject
+ end
+
+ context 'without branch name' do
+ let(:branch_name) { nil }
+
+ it 'calls JiraConnect::SyncService#execute' do
+ expect_jira_sync_service_execute(
+ branches: nil,
+ commits: project.commits_by(oids: commit_shas)
+ )
+
+ subject
+ end
+ end
+
+ context 'without commits' do
+ let(:commit_shas) { nil }
+
+ it 'calls JiraConnect::SyncService#execute' do
+ expect_jira_sync_service_execute(
+ branches: [instance_of(Gitlab::Git::Branch)],
+ commits: nil
+ )
+
+ subject
+ end
+ end
+
+ context 'when project no longer exists' do
+ let(:project_id) { non_existing_record_id }
+
+ it 'does not call JiraConnect::SyncService' do
+ expect(JiraConnect::SyncService).not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/workers/jira_connect/sync_merge_request_worker_spec.rb b/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
new file mode 100644
index 00000000000..764201e750a
--- /dev/null
+++ b/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SyncMergeRequestWorker do
+ describe '#perform' do
+ let(:merge_request) { create(:merge_request) }
+ let(:merge_request_id) { merge_request.id }
+
+ subject { described_class.new.perform(merge_request_id) }
+
+ it 'calls JiraConnect::SyncService#execute' do
+ expect_next_instance_of(JiraConnect::SyncService) do |service|
+ expect(service).to receive(:execute).with(merge_requests: [merge_request])
+ end
+
+ subject
+ end
+
+ context 'when MR no longer exists' do
+ let(:merge_request_id) { non_existing_record_id }
+
+ it 'does not call JiraConnect::SyncService' do
+ expect(JiraConnect::SyncService).not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/workers/merge_request_cleanup_refs_worker_spec.rb b/spec/workers/merge_request_cleanup_refs_worker_spec.rb
new file mode 100644
index 00000000000..88d7322536b
--- /dev/null
+++ b/spec/workers/merge_request_cleanup_refs_worker_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequestCleanupRefsWorker do
+ describe '#perform' do
+ context 'when merge request exists' do
+ let(:merge_request) { create(:merge_request) }
+ let(:job_args) { merge_request.id }
+
+ include_examples 'an idempotent worker' do
+ it 'calls MergeRequests::CleanupRefsService#execute' do
+ expect_next_instance_of(MergeRequests::CleanupRefsService, merge_request) do |svc|
+ expect(svc).to receive(:execute).and_call_original
+ end.twice
+
+ subject
+ end
+ end
+ end
+
+ context 'when merge request does not exist' do
+ it 'does not call MergeRequests::CleanupRefsService' do
+ expect(MergeRequests::CleanupRefsService).not_to receive(:new)
+
+ perform_multiple(1)
+ end
+ end
+ end
+end
diff --git a/spec/workers/new_issue_worker_spec.rb b/spec/workers/new_issue_worker_spec.rb
index 6386af8d253..7cba3487603 100644
--- a/spec/workers/new_issue_worker_spec.rb
+++ b/spec/workers/new_issue_worker_spec.rb
@@ -11,13 +11,13 @@ RSpec.describe NewIssueWorker do
expect(EventCreateService).not_to receive(:new)
expect(NotificationService).not_to receive(:new)
- worker.perform(99, create(:user).id)
+ worker.perform(non_existing_record_id, create(:user).id)
end
it 'logs an error' do
- expect(Rails.logger).to receive(:error).with('NewIssueWorker: couldn\'t find Issue with ID=99, skipping job')
+ expect(Gitlab::AppLogger).to receive(:error).with("NewIssueWorker: couldn't find Issue with ID=#{non_existing_record_id}, skipping job")
- worker.perform(99, create(:user).id)
+ worker.perform(non_existing_record_id, create(:user).id)
end
end
@@ -26,23 +26,23 @@ RSpec.describe NewIssueWorker do
expect(EventCreateService).not_to receive(:new)
expect(NotificationService).not_to receive(:new)
- worker.perform(create(:issue).id, 99)
+ worker.perform(create(:issue).id, non_existing_record_id)
end
it 'logs an error' do
issue = create(:issue)
- expect(Rails.logger).to receive(:error).with('NewIssueWorker: couldn\'t find User with ID=99, skipping job')
+ expect(Gitlab::AppLogger).to receive(:error).with("NewIssueWorker: couldn't find User with ID=#{non_existing_record_id}, skipping job")
- worker.perform(issue.id, 99)
+ worker.perform(issue.id, non_existing_record_id)
end
end
context 'when everything is ok' do
- let(:project) { create(:project, :public) }
- let(:mentioned) { create(:user) }
- let(:user) { create(:user) }
- let(:issue) { create(:issue, project: project, description: "issue for #{mentioned.to_reference}") }
+ let_it_be(:user) { create_default(:user) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:mentioned) { create(:user) }
+ let_it_be(:issue) { create(:issue, project: project, description: "issue for #{mentioned.to_reference}") }
it 'creates a new event record' do
expect { worker.perform(issue.id, user.id) }.to change { Event.count }.from(0).to(1)
@@ -50,7 +50,7 @@ RSpec.describe NewIssueWorker do
it 'creates a notification for the mentioned user' do
expect(Notify).to receive(:new_issue_email).with(mentioned.id, issue.id, NotificationReason::MENTIONED)
- .and_return(double(deliver_later: true))
+ .and_return(double(deliver_later: true))
worker.perform(issue.id, user.id)
end
diff --git a/spec/workers/new_merge_request_worker_spec.rb b/spec/workers/new_merge_request_worker_spec.rb
index 37449540db5..310fde4c7e1 100644
--- a/spec/workers/new_merge_request_worker_spec.rb
+++ b/spec/workers/new_merge_request_worker_spec.rb
@@ -11,15 +11,15 @@ RSpec.describe NewMergeRequestWorker do
expect(EventCreateService).not_to receive(:new)
expect(NotificationService).not_to receive(:new)
- worker.perform(99, create(:user).id)
+ worker.perform(non_existing_record_id, create(:user).id)
end
it 'logs an error' do
user = create(:user)
- expect(Rails.logger).to receive(:error).with('NewMergeRequestWorker: couldn\'t find MergeRequest with ID=99, skipping job')
+ expect(Gitlab::AppLogger).to receive(:error).with("NewMergeRequestWorker: couldn't find MergeRequest with ID=#{non_existing_record_id}, skipping job")
- worker.perform(99, user.id)
+ worker.perform(non_existing_record_id, user.id)
end
end
@@ -28,15 +28,15 @@ RSpec.describe NewMergeRequestWorker do
expect(EventCreateService).not_to receive(:new)
expect(NotificationService).not_to receive(:new)
- worker.perform(create(:merge_request).id, 99)
+ worker.perform(create(:merge_request).id, non_existing_record_id)
end
it 'logs an error' do
merge_request = create(:merge_request)
- expect(Rails.logger).to receive(:error).with('NewMergeRequestWorker: couldn\'t find User with ID=99, skipping job')
+ expect(Gitlab::AppLogger).to receive(:error).with("NewMergeRequestWorker: couldn't find User with ID=#{non_existing_record_id}, skipping job")
- worker.perform(merge_request.id, 99)
+ worker.perform(merge_request.id, non_existing_record_id)
end
end
@@ -54,8 +54,8 @@ RSpec.describe NewMergeRequestWorker do
it 'creates a notification for the mentioned user' do
expect(Notify).to receive(:new_merge_request_email)
- .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED)
- .and_return(double(deliver_later: true))
+ .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED)
+ .and_return(double(deliver_later: true))
worker.perform(merge_request.id, user.id)
end
diff --git a/spec/workers/new_note_worker_spec.rb b/spec/workers/new_note_worker_spec.rb
index 21f10fa5bfb..86b6d041e5c 100644
--- a/spec/workers/new_note_worker_spec.rb
+++ b/spec/workers/new_note_worker_spec.rb
@@ -50,10 +50,16 @@ RSpec.describe NewNoteWorker do
end
end
- context 'when note is with review' do
- it 'does not create a new note notification' do
- note = create(:note, :with_review)
+ context 'when note does not require notification' do
+ let(:note) { create(:note) }
+
+ before do
+ allow_next_found_instance_of(Note) do |note|
+ allow(note).to receive(:skip_notification?).and_return(true)
+ end
+ end
+ it 'does not create a new note notification' do
expect_any_instance_of(NotificationService).not_to receive(:new_note)
subject.perform(note.id)
diff --git a/spec/workers/pages_remove_worker_spec.rb b/spec/workers/pages_remove_worker_spec.rb
new file mode 100644
index 00000000000..638e87043f2
--- /dev/null
+++ b/spec/workers/pages_remove_worker_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PagesRemoveWorker do
+ let_it_be(:project) { create(:project, path: "my.project")}
+ let_it_be(:domain) { create(:pages_domain, project: project) }
+ subject { described_class.new.perform(project.id) }
+
+ it 'deletes published pages' do
+ expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true
+ expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything)
+
+ subject
+
+ expect(project.reload.pages_metadatum.deployed?).to be(false)
+ end
+
+ it 'deletes all domains' do
+ expect(project.pages_domains.count).to be 1
+
+ subject
+
+ expect(project.reload.pages_domains.count).to be 0
+ end
+end
diff --git a/spec/workers/pages_transfer_worker_spec.rb b/spec/workers/pages_transfer_worker_spec.rb
new file mode 100644
index 00000000000..248a3713bf6
--- /dev/null
+++ b/spec/workers/pages_transfer_worker_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PagesTransferWorker do
+ describe '#perform' do
+ Gitlab::PagesTransfer::Async::METHODS.each do |meth|
+ context "when method is #{meth}" do
+ let(:args) { [1, 2, 3] }
+
+ it 'calls the service with the given arguments' do
+ expect_next_instance_of(Gitlab::PagesTransfer) do |service|
+ expect(service).to receive(meth).with(*args).and_return(true)
+ end
+
+ subject.perform(meth, args)
+ end
+
+ it 'raises an error when the service returns false' do
+ expect_next_instance_of(Gitlab::PagesTransfer) do |service|
+ expect(service).to receive(meth).with(*args).and_return(false)
+ end
+
+ expect { subject.perform(meth, args) }
+ .to raise_error(described_class::TransferFailedError)
+ end
+ end
+ end
+
+ describe 'when method is not allowed' do
+ it 'does nothing' do
+ expect(Gitlab::PagesTransfer).not_to receive(:new)
+
+ subject.perform('object_id', [])
+ end
+ end
+ end
+end
diff --git a/spec/workers/pages_update_configuration_worker_spec.rb b/spec/workers/pages_update_configuration_worker_spec.rb
index 890b39b22a5..87bbff1a28b 100644
--- a/spec/workers/pages_update_configuration_worker_spec.rb
+++ b/spec/workers/pages_update_configuration_worker_spec.rb
@@ -17,14 +17,6 @@ RSpec.describe PagesUpdateConfigurationWorker do
subject.perform(project.id)
end
- it "raises an exception if the service returned an error" do
- allow_next_instance_of(Projects::UpdatePagesConfigurationService) do |service|
- allow(service).to receive(:execute).and_return({ exception: ":boom:" })
- end
-
- expect { subject.perform(project.id) }.to raise_error(":boom:")
- end
-
it_behaves_like "an idempotent worker" do
let(:job_args) { [project.id] }
let(:pages_dir) { Dir.mktmpdir }
diff --git a/spec/workers/partition_creation_worker_spec.rb b/spec/workers/partition_creation_worker_spec.rb
index 50ed9c901c1..37225cc1f79 100644
--- a/spec/workers/partition_creation_worker_spec.rb
+++ b/spec/workers/partition_creation_worker_spec.rb
@@ -4,16 +4,26 @@ require "spec_helper"
RSpec.describe PartitionCreationWorker do
describe '#perform' do
- let(:creator) { double(create_partitions: nil) }
+ subject { described_class.new.perform }
+
+ let(:creator) { instance_double('PartitionCreator', create_partitions: nil) }
+ let(:monitoring) { instance_double('PartitionMonitoring', report_metrics: nil) }
before do
allow(Gitlab::Database::Partitioning::PartitionCreator).to receive(:new).and_return(creator)
+ allow(Gitlab::Database::Partitioning::PartitionMonitoring).to receive(:new).and_return(monitoring)
end
it 'delegates to PartitionCreator' do
expect(creator).to receive(:create_partitions)
- described_class.new.perform
+ subject
+ end
+
+ it 'reports partition metrics' do
+ expect(monitoring).to receive(:report_metrics)
+
+ subject
end
end
end
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 676a419553f..3ff67f47523 100644
--- a/spec/workers/personal_access_tokens/expired_notification_worker_spec.rb
+++ b/spec/workers/personal_access_tokens/expired_notification_worker_spec.rb
@@ -9,32 +9,16 @@ RSpec.describe PersonalAccessTokens::ExpiredNotificationWorker, type: :worker do
context 'when a token has expired' do
let(:expired_today) { create(:personal_access_token, expires_at: Date.current) }
- context 'when feature is enabled' do
- it 'uses notification service to send email to the user' do
- expect_next_instance_of(NotificationService) do |notification_service|
- expect(notification_service).to receive(:access_token_expired).with(expired_today.user)
- end
-
- worker.perform
+ it 'uses notification service to send email to the user' do
+ expect_next_instance_of(NotificationService) do |notification_service|
+ expect(notification_service).to receive(:access_token_expired).with(expired_today.user)
end
- it 'updates notified column' do
- expect { worker.perform }.to change { expired_today.reload.after_expiry_notification_delivered }.from(false).to(true)
- end
+ worker.perform
end
- context 'when feature is disabled' do
- before do
- stub_feature_flags(expired_pat_email_notification: false)
- end
-
- it 'does not update notified column' do
- expect { worker.perform }.not_to change { expired_today.reload.after_expiry_notification_delivered }
- end
-
- it 'does not trigger email' do
- expect { worker.perform }.not_to change { ActionMailer::Base.deliveries.count }
- end
+ it 'updates notified column' do
+ expect { worker.perform }.to change { expired_today.reload.after_expiry_notification_delivered }.from(false).to(true)
end
end
diff --git a/spec/workers/pipeline_update_ci_ref_status_worker_service_spec.rb b/spec/workers/pipeline_update_ci_ref_status_worker_service_spec.rb
deleted file mode 100644
index 4e7af16c63d..00000000000
--- a/spec/workers/pipeline_update_ci_ref_status_worker_service_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-# NOTE: This class is unused and to be removed in 13.1~
-RSpec.describe PipelineUpdateCiRefStatusWorker do
- let(:worker) { described_class.new }
- let(:pipeline) { create(:ci_pipeline) }
-
- describe '#perform' do
- it 'updates the ci_ref status' do
- expect(Ci::UpdateCiRefStatusService).to receive(:new)
- .with(pipeline)
- .and_return(double(call: true))
-
- worker.perform(pipeline.id)
- end
- end
-end
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index f64ee4aa2f7..50d164d1705 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe PostReceive do
context 'with a non-existing project' do
let(:gl_repository) { "project-123456789" }
let(:error_message) do
- "Triggered hook for non-existing project with gl_repository \"#{gl_repository}\""
+ "Triggered hook for non-existing gl_repository \"#{gl_repository}\""
end
it "returns false and logs an error" do
@@ -314,7 +314,7 @@ RSpec.describe PostReceive do
it 'processes the changes on the master branch' do
expect_next_instance_of(Git::WikiPushService) do |service|
- expect(service).to receive(:process_changes).and_call_original
+ expect(service).to receive(:execute).and_call_original
end
expect(project.wiki).to receive(:default_branch).twice.and_return(default_branch)
expect(project.wiki.repository).to receive(:raw).and_return(raw_repo)
@@ -334,7 +334,7 @@ RSpec.describe PostReceive do
before do
allow_next_instance_of(Git::WikiPushService) do |service|
- allow(service).to receive(:process_changes)
+ allow(service).to receive(:execute)
end
end
diff --git a/spec/workers/propagate_integration_worker_spec.rb b/spec/workers/propagate_integration_worker_spec.rb
index 3fe76f14750..b8c7f2bebe7 100644
--- a/spec/workers/propagate_integration_worker_spec.rb
+++ b/spec/workers/propagate_integration_worker_spec.rb
@@ -17,8 +17,13 @@ RSpec.describe PropagateIntegrationWorker do
end
it 'calls the propagate service with the integration' do
- expect(Admin::PropagateIntegrationService).to receive(:propagate)
- .with(integration: integration, overwrite: true)
+ expect(Admin::PropagateIntegrationService).to receive(:propagate).with(integration)
+
+ subject.perform(integration.id)
+ end
+
+ it 'ignores overwrite parameter from previous version' do
+ expect(Admin::PropagateIntegrationService).to receive(:propagate).with(integration)
subject.perform(integration.id, true)
end
diff --git a/spec/workers/propagate_service_template_worker_spec.rb b/spec/workers/propagate_service_template_worker_spec.rb
index 48151b25d4b..793f0b9b08c 100644
--- a/spec/workers/propagate_service_template_worker_spec.rb
+++ b/spec/workers/propagate_service_template_worker_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe PropagateServiceTemplateWorker do
stub_exclusive_lease("propagate_service_template_worker:#{template.id}",
timeout: PropagateServiceTemplateWorker::LEASE_TIMEOUT)
- expect(Projects::PropagateServiceTemplate)
+ expect(Admin::PropagateServiceTemplate)
.to receive(:propagate)
.with(template)
diff --git a/spec/workers/remote_mirror_notification_worker_spec.rb b/spec/workers/remote_mirror_notification_worker_spec.rb
index c6fd614fdea..e415e72645c 100644
--- a/spec/workers/remote_mirror_notification_worker_spec.rb
+++ b/spec/workers/remote_mirror_notification_worker_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe RemoteMirrorNotificationWorker, :mailer do
let_it_be(:project) { create(:project, :repository, :remote_mirror) }
let_it_be(:mirror) { project.remote_mirrors.first }
- describe '#execute' do
+ describe '#perform' do
it 'calls NotificationService#remote_mirror_update_failed when the mirror exists' do
mirror.update_column(:last_error, "There was a problem fetching")
diff --git a/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb b/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb
index f14c2b67f2c..21b9a7b844b 100644
--- a/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb
+++ b/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb
@@ -16,21 +16,21 @@ RSpec.describe RemoveUnreferencedLfsObjectsWorker do
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
- )
+ )
end
let!(:lfs_objects_project1_2) do
create(:lfs_objects_project,
project: project1,
lfs_object: referenced_lfs_object2
- )
+ )
end
it 'removes unreferenced lfs objects' do
diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb
index 0f2d4eb4b65..9c46b1e2a87 100644
--- a/spec/workers/repository_fork_worker_spec.rb
+++ b/spec/workers/repository_fork_worker_spec.rb
@@ -87,7 +87,7 @@ RSpec.describe RepositoryForkWorker do
it 'calls Projects::LfsPointers::LfsLinkService#execute with OIDs of source project LFS objects' do
expect_fork_repository(success: true)
expect_next_instance_of(Projects::LfsPointers::LfsLinkService) do |service|
- expect(service).to receive(:execute).with(project.all_lfs_objects_oids)
+ expect(service).to receive(:execute).with(project.lfs_objects_oids)
end
perform!
diff --git a/spec/workers/repository_update_remote_mirror_worker_spec.rb b/spec/workers/repository_update_remote_mirror_worker_spec.rb
index c6e667097ec..858f5226c48 100644
--- a/spec/workers/repository_update_remote_mirror_worker_spec.rb
+++ b/spec/workers/repository_update_remote_mirror_worker_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe RepositoryUpdateRemoteMirrorWorker, :clean_gitlab_redis_shared_st
let(:scheduled_time) { Time.current - 5.minutes }
around do |example|
- Timecop.freeze(Time.current) { example.run }
+ freeze_time { example.run }
end
def expect_mirror_service_to_return(mirror, result, tries = 0)
diff --git a/spec/workers/run_pipeline_schedule_worker_spec.rb b/spec/workers/run_pipeline_schedule_worker_spec.rb
index 4999909934b..0b9f95e09fe 100644
--- a/spec/workers/run_pipeline_schedule_worker_spec.rb
+++ b/spec/workers/run_pipeline_schedule_worker_spec.rb
@@ -59,7 +59,7 @@ RSpec.describe RunPipelineScheduleWorker do
end
it 'logging a pipeline error' do
- expect(Rails.logger)
+ expect(Gitlab::AppLogger)
.to receive(:error)
.with(a_string_matching('ActiveRecord::StatementInvalid'))
.and_call_original